From 2484c770948a7bad8231d60a4f2e6651f9011b09 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:26:24 +0200 Subject: [PATCH 01/98] Update de_DE 1 --- locales/de-DE/opencadstudio.ftl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 7a6ecdc9..9ee6921e 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -29,7 +29,7 @@ action = ribbon-tab = .draw = Zeichnen .annotate = Beschriften - .insert = Einsatz + .insert = Einfügen .model = Modell .layout = Layout .manage = Verwalten @@ -68,7 +68,7 @@ start = .new-drawing = Neue Zeichnung .open-file = Datei öffnen… .donate = Spenden - .send-feedback = Feedback senden + .send-feedback = Rückmeldung senden .sponsors = Sponsoren .tutorials = Lernprogramme .loading-videos = Videos werden geladen… From 4fd94d324a980911bb5d3016845770ec9321a99f Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:40:39 +0200 Subject: [PATCH 02/98] fix(ipc): read response byte-by-byte in request_with_poll to avoid TCP desync --- crates/ocs_plugin_api/src/ipc/proxy.rs | 120 +++++++++++++++++++++---- 1 file changed, 103 insertions(+), 17 deletions(-) diff --git a/crates/ocs_plugin_api/src/ipc/proxy.rs b/crates/ocs_plugin_api/src/ipc/proxy.rs index 1300e18d..e4d19825 100644 --- a/crates/ocs_plugin_api/src/ipc/proxy.rs +++ b/crates/ocs_plugin_api/src/ipc/proxy.rs @@ -11,7 +11,7 @@ //! formats or enum variants. use std::io::{Read, Write}; -use std::net::{Shutdown, TcpListener, TcpStream}; +use std::net::{TcpListener, TcpStream}; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; @@ -79,6 +79,50 @@ pub fn recv_framed(reader: &mut R) -> R Ok(msg) } +/// Read exactly `buf.len()` bytes from `reader`, calling `poll` whenever no +/// data is currently available. Unlike `read_exact`, this preserves partial +/// progress across short read timeouts so the stream can never get out of sync. +fn read_with_poll( + reader: &mut R, + buf: &mut [u8], + poll: &mut dyn FnMut() -> Result<(), PluginRequestError>, +) -> Result<(), PluginRequestError> { + use std::io::ErrorKind; + let mut off = 0; + while off < buf.len() { + match reader.read(&mut buf[off..]) { + Ok(0) => return Err(PluginRequestError("proxy disconnected".to_string())), + Ok(n) => off += n, + Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => { + poll()?; + } + Err(e) => return Err(ProxyError::Io(e).into()), + } + } + Ok(()) +} + +/// Receive a length-framed bincode message with polling. The length prefix and +/// payload are read incrementally so a timeout never splits a message. +fn recv_framed_with_poll( + reader: &mut R, + poll: &mut dyn FnMut() -> Result<(), PluginRequestError>, +) -> Result { + let mut len_buf = [0u8; 8]; + read_with_poll(reader, &mut len_buf, poll)?; + let len = u64::from_le_bytes(len_buf) as usize; + if len == 0 { + return Err(ProxyError::Empty.into()); + } + if len > MAX_MESSAGE_SIZE { + return Err(ProxyError::TooLarge(len).into()); + } + let mut buf = vec![0u8; len]; + read_with_poll(reader, &mut buf, poll)?; + let msg = bincode::deserialize(&buf).map_err(ProxyError::Encode)?; + Ok(msg) +} + /// Thread-safe [`PluginRequestSender`] implementation that forwards requests /// over a single TCP stream to a request proxy server. pub struct ProxyPluginRequestSender { @@ -113,12 +157,15 @@ impl ProxyPluginRequestSender { /// [`POLL_INTERVAL`] while waiting. This lets Python callers run /// `py.check_signals()` so that `Ctrl+C`/`KeyboardInterrupt` is raised /// promptly instead of waiting for the full socket timeout. + /// + /// The response is read byte-by-byte with polling so that a short timeout + /// can never leave the TCP stream in a partially-read state between the + /// length prefix and the payload. pub fn request_with_poll( &self, req: PluginRequest, poll: &mut dyn FnMut() -> Result<(), PluginRequestError>, ) -> Result { - use std::io::ErrorKind; let mut stream = self .stream .lock() @@ -130,21 +177,9 @@ impl ProxyPluginRequestSender { stream .set_read_timeout(Some(POLL_INTERVAL)) .map_err(|e| PluginRequestError(e.to_string()))?; - let result = loop { - match recv_framed(&mut *stream) { - Ok(resp) => break Ok(resp), - Err(ProxyError::Io(e)) - if e.kind() == ErrorKind::WouldBlock - || e.kind() == ErrorKind::TimedOut => - { - if let Err(e) = poll() { - let _ = stream.shutdown(Shutdown::Both); - break Err(e); - } - } - Err(e) => break Err(e.into()), - } - }; + + let result = recv_framed_with_poll(&mut *stream, poll); + let _ = stream.set_read_timeout(original_timeout); result } @@ -331,4 +366,55 @@ mod tests { .unwrap(); assert!(matches!(resp, PluginResponse::Ok)); } + + /// A reader that returns one byte per call and then `WouldBlock` on the + /// next call, exercising both the incremental read and the poll path. + struct OneByteThenBlock<'a> { + data: &'a [u8], + block_next: bool, + } + + impl<'a> Read for OneByteThenBlock<'a> { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if self.block_next { + self.block_next = false; + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "simulated block", + )); + } + if self.data.is_empty() { + return Ok(0); + } + let n = buf.len().min(1); + buf[..n].copy_from_slice(&self.data[..n]); + self.data = &self.data[n..]; + self.block_next = true; + Ok(n) + } + } + + #[test] + fn recv_framed_with_poll_reads_one_byte_at_a_time() { + let msg = PluginResponse::Bool(true); + let bytes = bincode::serialize(&msg).unwrap(); + let len = bytes.len() as u64; + let mut framed = Vec::with_capacity(8 + bytes.len()); + framed.extend_from_slice(&len.to_le_bytes()); + framed.extend_from_slice(&bytes); + + let mut reader = OneByteThenBlock { + data: &framed, + block_next: false, + }; + let mut polls = 0; + let resp = recv_framed_with_poll(&mut reader, &mut || { + polls += 1; + Ok(()) + }) + .unwrap(); + assert!(matches!(resp, PluginResponse::Bool(true))); + // One byte, one block for every byte except the last (no poll after EOF). + assert_eq!(polls, framed.len() - 1); + } } From a67affcb9349f4b1da41564c83da619117724259 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 23 Aug 2026 10:46:27 +0200 Subject: [PATCH 03/98] fix(ipc): preserve stream shutdown on interrupt during polled reads --- crates/ocs_plugin_api/src/ipc/proxy.rs | 52 ++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/src/ipc/proxy.rs b/crates/ocs_plugin_api/src/ipc/proxy.rs index e4d19825..e4c6bd2a 100644 --- a/crates/ocs_plugin_api/src/ipc/proxy.rs +++ b/crates/ocs_plugin_api/src/ipc/proxy.rs @@ -11,7 +11,7 @@ //! formats or enum variants. use std::io::{Read, Write}; -use std::net::{TcpListener, TcpStream}; +use std::net::{Shutdown, TcpListener, TcpStream}; use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; @@ -79,6 +79,29 @@ pub fn recv_framed(reader: &mut R) -> R Ok(msg) } +/// Error during a polled read: either an IO error on the stream, or an +/// interrupt reported by the poll callback (e.g. the user pressed Ctrl+C). +#[derive(Debug)] +enum PollError { + Io(ProxyError), + Interrupted(PluginRequestError), +} + +impl From for PollError { + fn from(e: ProxyError) -> Self { + PollError::Io(e) + } +} + +impl From for PluginRequestError { + fn from(e: PollError) -> Self { + match e { + PollError::Io(err) => err.into(), + PollError::Interrupted(err) => err, + } + } +} + /// Read exactly `buf.len()` bytes from `reader`, calling `poll` whenever no /// data is currently available. Unlike `read_exact`, this preserves partial /// progress across short read timeouts so the stream can never get out of sync. @@ -86,17 +109,22 @@ fn read_with_poll( reader: &mut R, buf: &mut [u8], poll: &mut dyn FnMut() -> Result<(), PluginRequestError>, -) -> Result<(), PluginRequestError> { +) -> Result<(), PollError> { use std::io::ErrorKind; let mut off = 0; while off < buf.len() { match reader.read(&mut buf[off..]) { - Ok(0) => return Err(PluginRequestError("proxy disconnected".to_string())), + Ok(0) => return Err(PollError::Io(ProxyError::Io(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "proxy disconnected", + )))), Ok(n) => off += n, Err(e) if e.kind() == ErrorKind::WouldBlock || e.kind() == ErrorKind::TimedOut => { - poll()?; + if let Err(err) = poll() { + return Err(PollError::Interrupted(err)); + } } - Err(e) => return Err(ProxyError::Io(e).into()), + Err(e) => return Err(PollError::Io(ProxyError::Io(e))), } } Ok(()) @@ -107,7 +135,7 @@ fn read_with_poll( fn recv_framed_with_poll( reader: &mut R, poll: &mut dyn FnMut() -> Result<(), PluginRequestError>, -) -> Result { +) -> Result { let mut len_buf = [0u8; 8]; read_with_poll(reader, &mut len_buf, poll)?; let len = u64::from_le_bytes(len_buf) as usize; @@ -178,7 +206,17 @@ impl ProxyPluginRequestSender { .set_read_timeout(Some(POLL_INTERVAL)) .map_err(|e| PluginRequestError(e.to_string()))?; - let result = recv_framed_with_poll(&mut *stream, poll); + let result = match recv_framed_with_poll(&mut *stream, poll) { + Ok(resp) => Ok(resp), + Err(PollError::Interrupted(e)) => { + // The caller (e.g. Python's Ctrl+C handler) asked us to stop + // waiting. Shut down the stream immediately so the proxy server + // and any spawned runners can observe the disconnect. + let _ = stream.shutdown(Shutdown::Both); + Err(e) + } + Err(PollError::Io(e)) => Err(e.into()), + }; let _ = stream.set_read_timeout(original_timeout); result From 94f5971fdb95c47873f2bcb4ca9368eb67311fc0 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 13:23:26 +0300 Subject: [PATCH 04/98] docs(ipc): clarify polled response reads --- crates/ocs_plugin_api/src/ipc/proxy.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/crates/ocs_plugin_api/src/ipc/proxy.rs b/crates/ocs_plugin_api/src/ipc/proxy.rs index e4c6bd2a..e7729fcd 100644 --- a/crates/ocs_plugin_api/src/ipc/proxy.rs +++ b/crates/ocs_plugin_api/src/ipc/proxy.rs @@ -185,10 +185,7 @@ impl ProxyPluginRequestSender { /// [`POLL_INTERVAL`] while waiting. This lets Python callers run /// `py.check_signals()` so that `Ctrl+C`/`KeyboardInterrupt` is raised /// promptly instead of waiting for the full socket timeout. - /// - /// The response is read byte-by-byte with polling so that a short timeout - /// can never leave the TCP stream in a partially-read state between the - /// length prefix and the payload. + /// Partial reads are preserved across timeouts to keep framing synchronized. pub fn request_with_poll( &self, req: PluginRequest, @@ -209,9 +206,7 @@ impl ProxyPluginRequestSender { let result = match recv_framed_with_poll(&mut *stream, poll) { Ok(resp) => Ok(resp), Err(PollError::Interrupted(e)) => { - // The caller (e.g. Python's Ctrl+C handler) asked us to stop - // waiting. Shut down the stream immediately so the proxy server - // and any spawned runners can observe the disconnect. + // A partial response cannot be reused after interruption. let _ = stream.shutdown(Shutdown::Both); Err(e) } From 682e16210f96d4b143ec1e725941253558d6da55 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 13:49:18 +0300 Subject: [PATCH 05/98] fix(insert): honor preset attributes Refs #853 --- src/app/command_driver.rs | 9 ++- src/command.rs | 11 ++-- src/modules/insert/insert_block.rs | 99 ++++++++++++++++++------------ 3 files changed, 72 insertions(+), 47 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index f1924bc7..e84005b7 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1731,9 +1731,12 @@ impl OpenCADStudio { } } } else { - // Inject attdefs so the command enters attr-filling mode. - if let Some(cmd) = &mut self.tabs[i].active_cmd { - cmd.attreq_set_attdefs(attdefs); + let completed = self.tabs[i] + .active_cmd + .as_mut() + .and_then(|cmd| cmd.attreq_set_attdefs(attdefs)); + if let Some(entity) = completed { + return self.apply_cmd_result(CmdResult::CommitAndExit(entity)); } let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { diff --git a/src/command.rs b/src/command.rs index 39e086e4..fcc8344f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1945,10 +1945,13 @@ pub trait CadCommand: Send { None } - /// Inject the block's attribute definitions for ATTREQ attr-filling after - /// the INSERT point is picked. Carries the full definitions so the created - /// attributes inherit their geometry, not just tag / prompt / default (#255). - fn attreq_set_attdefs(&mut self, _attdefs: Vec) {} + /// Inject block attribute definitions after the INSERT point is picked. + fn attreq_set_attdefs( + &mut self, + _attdefs: Vec, + ) -> Option { + None + } /// Returns the INSERT entity built so far (pending attr fill) if this is an /// ATTREQ-aware INSERT command waiting for attdef injection. diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index 89c3caf6..86d39bab 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -22,16 +22,10 @@ enum Step { Point { name: String, }, - /// Attribute filling: prompt the user tag by tag. FillAttr { - /// The block's attribute definitions — carried in full so the created - /// attributes inherit their position / alignment / height / rotation / - /// style, not just tag + prompt + default (#255). attdefs: Vec, - /// Index of the attdef currently being prompted. idx: usize, - /// (tag, value) pairs collected so far, in attdef order. - values: Vec<(String, String)>, + values: Vec<(usize, String)>, }, } @@ -268,12 +262,16 @@ impl CadCommand for InsertBlockCommand { } } - fn attreq_set_attdefs(&mut self, attdefs: Vec) { + fn attreq_set_attdefs( + &mut self, + attdefs: Vec, + ) -> Option { self.step = Step::FillAttr { attdefs, idx: 0, values: vec![], }; + self.advance_automatic_attributes() } fn attreq_take_insert(&mut self) -> Option { @@ -284,13 +282,13 @@ impl CadCommand for InsertBlockCommand { } impl InsertBlockCommand { - /// Accept the current attribute value (empty = use default) and advance. - /// Returns CommitAndExit when all attdefs have been filled. fn accept_attr_value(&mut self, text: &str) -> CmdResult { - let (tag, default, next_idx, total) = match &self.step { + let (attdef_idx, default) = match &self.step { Step::FillAttr { attdefs, idx, .. } => { - let ad = &attdefs[*idx]; - (ad.tag.clone(), ad.default_value.clone(), idx + 1, attdefs.len()) + let Some(ad) = attdefs.get(*idx) else { + return CmdResult::Cancel; + }; + (*idx, ad.default_value.clone()) } _ => return CmdResult::Cancel, }; @@ -307,37 +305,58 @@ impl InsertBlockCommand { .. } = self.step { - values.push((tag, value)); - *idx = next_idx; + values.push((attdef_idx, value)); + *idx = attdef_idx + 1; } - if next_idx >= total { - // All attdefs filled — build the INSERT's attribute list. Each - // attribute inherits its ATTDEF's geometry (position, alignment, - // height, rotation, style) via `from_definition`, then the block's - // insertion transform places it into WCS so it lands where the block - // put it instead of stacking at the origin (#255). - let (attdefs, values) = match &self.step { - Step::FillAttr { - attdefs, values, .. - } => (attdefs.clone(), values.clone()), - _ => (vec![], vec![]), - }; - let mut ins = match self.pending_insert.take() { - Some(i) => i, - None => return CmdResult::Cancel, - }; - let xform = ins.get_transform(); - for (ad, (_tag, value)) in attdefs.iter().zip(values.iter()) { - let mut attr = AttributeEntity::from_definition(ad, Some(value.clone())); - attr.apply_transform(&xform); - ins.attributes.push(attr); - } - CmdResult::CommitAndExit(EntityType::Insert(ins)) - } else { - CmdResult::NeedPoint + match self.advance_automatic_attributes() { + Some(entity) => CmdResult::CommitAndExit(entity), + None => CmdResult::NeedPoint, } } + + fn advance_automatic_attributes(&mut self) -> Option { + loop { + let next = match &self.step { + Step::FillAttr { attdefs, idx, .. } => attdefs.get(*idx).map(|ad| { + (*idx, ad.flags.constant, ad.flags.preset, ad.default_value.clone()) + }), + _ => return None, + }; + let Some((attdef_idx, constant, preset, default)) = next else { + return self.finish_insert(); + }; + if !constant && !preset { + return None; + } + if let Step::FillAttr { idx, values, .. } = &mut self.step { + if !constant { + values.push((attdef_idx, default)); + } + *idx += 1; + } + } + } + + fn finish_insert(&mut self) -> Option { + let (attdefs, values) = match &self.step { + Step::FillAttr { + attdefs, values, .. + } => (attdefs.clone(), values.clone()), + _ => return None, + }; + let mut insert = self.pending_insert.take()?; + let transform = insert.get_transform(); + for (attdef_idx, value) in values { + let Some(attdef) = attdefs.get(attdef_idx) else { + continue; + }; + let mut attribute = AttributeEntity::from_definition(attdef, Some(value)); + attribute.apply_transform(&transform); + insert.attributes.push(attribute); + } + Some(EntityType::Insert(insert)) + } } From 73ba2b92cc79d90b044dc7a6794c3ef578ef7f29 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 14:19:01 +0300 Subject: [PATCH 06/98] fix(view): preserve rotated orientation --- src/scene/view/camera.rs | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index b96a6941..8fbbe1d1 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -461,11 +461,7 @@ impl Camera { } } - /// The camera's roll — rotation about the view axis, in radians — the - /// inverse of the `roll` argument to [`yaw_pitch_to_quat`]. Recovered by - /// removing the yaw/pitch frame from the live rotation, so a saved view can - /// store its twist and round-trip it. Exact for a plan view (the twisted- - /// UCS case); approximate after a free 3D orbit. + /// The camera's rotation about its view axis. pub fn roll(&self) -> f32 { let q_yp = yaw_pitch_to_quat(self.yaw, self.pitch, 0.0); let q_roll = q_yp.conjugate() * self.rotation; @@ -767,16 +763,15 @@ impl Camera { // ── Internal helpers ─────────────────────────────────────────────────── - /// Derive yaw and pitch from the current quaternion for the ViewCube - /// hit-test functions (hit_test / hover_id). These use yaw/pitch to - /// compute the same rotation matrix as the shader, so they must match. + /// Derive yaw and pitch from the current quaternion. fn sync_yaw_pitch(&mut self) { - // Eye direction in world space (canonical eye dir is +Z). let eye_dir = self.rotation * Vec3::Z; - // pitch: angle above/below the XY plane. - self.pitch = eye_dir.z.clamp(-0.999, 0.999).asin(); - // yaw: atan2(x, y) matches from_rotation_z(yaw) used in view_rotation_mat. - self.yaw = eye_dir.x.atan2(eye_dir.y); + self.pitch = eye_dir.z.clamp(-1.0, 1.0).asin(); + self.yaw = if eye_dir.x.abs() < 1e-6 && eye_dir.y.abs() < 1e-6 { + 0.0 + } else { + eye_dir.x.atan2(-eye_dir.y) + }; } } @@ -787,7 +782,7 @@ impl Camera { /// so snap angles continue to work unchanged. /// /// Convention (Z-up, Y-forward): -/// yaw = 0 → camera looks along +Y axis (front view) +/// yaw = 0 → camera looks along -Y axis (front view) /// pitch = PI/2 → camera looks down -Z (top view) /// pitch = 0 → camera in the XY plane /// Build a rotation quaternion from yaw, pitch and roll. @@ -795,7 +790,7 @@ impl Camera { /// Roll rotates the camera around its own view axis (post-multiplied so it /// composes after the yaw/pitch gaze direction is set). pub fn yaw_pitch_to_quat(yaw: f32, pitch: f32, roll: f32) -> Quat { - // +yaw so ViewCube faces match camera direction (FRONT at yaw=0 = +Y world axis). + // +yaw keeps the ViewCube faces aligned with the camera direction. let q_yaw = Quat::from_rotation_z(yaw); let q_pitch = Quat::from_rotation_x(std::f32::consts::FRAC_PI_2 - pitch); let q_roll = Quat::from_rotation_z(roll); From c4515cbbc1fd70f575ba29f8a23f3c220996cae1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 14:19:05 +0300 Subject: [PATCH 07/98] fix(save): support autosave recovery files --- src/io/mod.rs | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/io/mod.rs b/src/io/mod.rs index 20458481..a839bd2a 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -695,16 +695,17 @@ async fn load_web_bytes( Ok((name.to_string(), path, doc, caches)) } -/// Parse a CAD document from in-memory bytes, choosing the format from -/// `name`'s extension. Used by the web build, where files arrive as bytes from -/// a browser file picker (no filesystem path). Shares the post-load fixups with -/// [`load_file`]; raster-image path resolution is skipped (there is no sibling -/// directory to search on the web). +/// Parse a drawing from bytes using its name or recovery-file signature. #[cfg(not(target_arch = "wasm32"))] pub fn load_bytes(name: &str, bytes: Vec) -> Result { use std::io::Cursor; let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase(); - match ext.as_str() { + let format = if matches!(ext.as_str(), "bak" | "sv$") { + sniff_dwg_or_dxf_bytes(&bytes) + } else { + ext.as_str() + }; + match format { "dwg" => { let mut doc = DwgReader::from_stream(Cursor::new(bytes)) .read() @@ -737,10 +738,14 @@ fn sniff_dwg_or_dxf(path: &Path) -> String { if let Ok(mut f) = std::fs::File::open(path) { let _ = f.read(&mut buf); } - if buf.starts_with(b"AC10") { - "dwg".to_string() + sniff_dwg_or_dxf_bytes(&buf).to_string() +} + +fn sniff_dwg_or_dxf_bytes(bytes: &[u8]) -> &'static str { + if bytes.starts_with(b"AC10") { + "dwg" } else { - "dxf".to_string() + "dxf" } } @@ -1345,7 +1350,7 @@ fn validate_save_extension(path: &Path) -> Result<(), SaveFailure> { .extension() .map(|value| value.to_string_lossy().to_lowercase()) .unwrap_or_default(); - if matches!(extension.as_str(), "dwg" | "dxf") { + if matches!(extension.as_str(), "dwg" | "dxf" | "sv$") { return Ok(()); } From e94cbedaa2bf73b231ac4e1b88ba695ae8e1401d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 17:59:12 +0300 Subject: [PATCH 08/98] fix(save): preserve 3D view and thumbnails Persist camera, render state, and solid fallback geometry across saves. Generate previews from a clean live viewport and refresh recent-file thumbnails immediately. --- src/app/document.rs | 6 - src/app/mod.rs | 58 +++- src/app/update/file.rs | 546 ++++++++++++++++++++---------- src/app/update/mod.rs | 24 ++ src/app/update/viewport.rs | 2 + src/app/view/mod.rs | 46 ++- src/io/thumbnail.rs | 426 +++-------------------- src/scene/camera_ops.rs | 26 +- src/scene/convert/solid3d_tess.rs | 2 + src/scene/entity.rs | 5 +- src/scene/mod.rs | 14 +- src/scene/paper.rs | 23 +- src/scene/view/camera.rs | 51 +-- src/scene/view/render.rs | 51 ++- src/scene/view/viewport_pane.rs | 7 + 15 files changed, 658 insertions(+), 629 deletions(-) diff --git a/src/app/document.rs b/src/app/document.rs index 09048da4..5893af52 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -195,10 +195,6 @@ pub(super) struct DocumentTab { pub(super) active_mleader_style: String, /// Last camera_generation value written back to the document. pub(super) last_synced_camera_gen: u64, - /// Render-state key of `scene.document.preview`. Matching saves reuse the - /// encoded DWG thumbnail instead of rescanning every resident wire. - #[cfg(not(target_arch = "wasm32"))] - pub(super) thumbnail_cache_key: Option, /// Sentinel "Welcome / Start" tab. Always at index 0 when present. /// Cannot be closed; the viewport area renders a welcome page instead /// of the model-space shader. The scene is still constructed so the @@ -486,8 +482,6 @@ impl DocumentTab { active_block_edit: None, active_mleader_style: "Standard".to_string(), last_synced_camera_gen: 0, - #[cfg(not(target_arch = "wasm32"))] - thumbnail_cache_key: None, is_start: false, pan_mode: false, orbit_mode: false, diff --git a/src/app/mod.rs b/src/app/mod.rs index 71afb464..9e587708 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -608,6 +608,12 @@ pub(super) struct OpenCADStudio { /// OS window Id for the floating Layer Properties Manager (None when closed). /// OS window Id of the primary application window. main_window: Option, + /// Hides drawing UI overlays for one thumbnail capture frame. + thumbnail_capture_clean: bool, + #[cfg(not(target_arch = "wasm32"))] + pending_native_thumbnail_save: Option, + #[cfg(target_arch = "wasm32")] + pending_web_thumbnail_save: Option, // ── Floating panel windows ──────────────────────────────────────────── /// Active `iced_aw` colour picker: destination plus its initial colour. color_pick_target: Option<(ColorPickTarget, AcadColor)>, @@ -1131,6 +1137,28 @@ pub(super) enum SaveContinuation { Quit, } +#[derive(Debug, Clone)] +#[cfg(not(target_arch = "wasm32"))] +pub(super) struct PendingNativeThumbnailSave { + tab_id: u64, + path: PathBuf, + version: acadrust::DxfVersion, + purpose: SavePurpose, + continuation: SaveContinuation, + set_current_path: bool, + check_external_change: bool, +} + +#[derive(Debug, Clone)] +#[cfg(target_arch = "wasm32")] +pub(super) struct PendingWebThumbnailSave { + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + bounds: iced::Rectangle, +} + #[derive(Debug, Clone)] #[cfg(not(target_arch = "wasm32"))] pub(super) struct PendingSaveFailure { @@ -1154,16 +1182,6 @@ pub(super) struct PendingExternalChange { set_current_path: bool, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -#[cfg(not(target_arch = "wasm32"))] -pub(super) struct ThumbnailCacheKey { - epoch: u64, - camera_generation: u64, - bg_color: [u32; 4], - png: bool, - viewport: [u32; 2], -} - #[derive(Debug, Clone)] #[cfg(not(target_arch = "wasm32"))] pub struct SaveOutcome { @@ -1178,7 +1196,6 @@ pub struct SaveOutcome { set_current_path: bool, purpose: SavePurpose, continuation: SaveContinuation, - thumbnail_key: Option, refreshed_preview: Option>, result: Result<(), crate::io::SaveFailure>, } @@ -1915,9 +1932,23 @@ pub enum Message { AecDropBack, /// Periodic autosave tick — write `.sv$` recovery files for dirty tabs. AutoSave, + /// A clean viewport frame is ready for thumbnail capture. + ThumbnailCaptureFrame, + /// Restore drawing UI after the compositor screenshot is captured. + ThumbnailCaptureFinished, /// Native background save/autosave completed. #[cfg(not(target_arch = "wasm32"))] SaveFinished(SaveOutcome), + /// Web viewport capture completed; serialize and download the drawing. + #[cfg(target_arch = "wasm32")] + WebSaveScreenshot { + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + bounds: Option, + screenshot: Option, + }, /// Retry the failed save after the other application releases the file. #[cfg(not(target_arch = "wasm32"))] SaveFileInUseRetry, @@ -3161,6 +3192,11 @@ impl OpenCADStudio { show_layout_tabs: true, last_point: None, main_window: None, + thumbnail_capture_clean: false, + #[cfg(not(target_arch = "wasm32"))] + pending_native_thumbnail_save: None, + #[cfg(target_arch = "wasm32")] + pending_web_thumbnail_save: None, color_pick_target: None, color_picker_tab: ColorPickerTab::Index, recent_colors: Vec::new(), diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 20cb1700..5ea4f4e4 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -237,36 +237,72 @@ fn plot_scene_content( } impl OpenCADStudio { - /// Before a save, give every cached solid that still has no ACIS - /// geometry (EXTRUDE/REVOLVE/SWEEP/LOFT/boolean results) an exact modeler - /// body derived from its B-rep, so the written DWG/DXF carries real - /// 3-D geometry other CAD apps can open instead of an empty data stream. - /// A body holding something the kernel has no ACIS record form for is - /// left untouched rather than written out half-complete. - fn sync_solid_models_to_acis(&mut self, i: usize) { + /// Persist exact ACIS bodies and kernel-derived edge caches before saving. + fn sync_solid_models_for_save(&mut self, i: usize) { use acadrust::EntityType; let scene = &mut self.tabs[i].scene; - let targets: Vec = scene - .solid_models - .keys() - .copied() - .filter(|h| { - matches!( - scene.document.get_entity(*h), - Some(EntityType::Solid3D(s)) if !s.acis_data.has_data() - ) + let targets: Vec<(acadrust::Handle, bool, bool)> = scene + .document + .entities() + .filter_map(|entity| { + let EntityType::Solid3D(solid) = entity else { + return None; + }; + let h = solid.common.handle; + let needs_acis = !solid.acis_data.has_data(); + let needs_wires = solid.wires.is_empty(); + (needs_acis || needs_wires).then_some((h, needs_acis, needs_wires)) }) .collect(); - for h in targets { - // Build the SAT while borrowing solid_models; the returned document - // is owned, so the borrow ends before we mutate the entity. - let sat = scene - .solid_models - .get(&h) - .and_then(crate::scene::convert::acis_export::planar_solid_to_sat); - if let Some(sat) = sat { - if let Some(EntityType::Solid3D(s)) = scene.document.get_entity_mut(h) { - s.set_sat_document(&sat); + for (h, needs_acis, needs_wires) in targets { + let body = scene.solid_models.get(&h); + let sat = needs_acis + .then(|| { + body.and_then(crate::scene::convert::acis_export::planar_solid_to_sat) + }) + .flatten(); + let wires = needs_wires.then(|| { + if let Some(body) = body { + return crate::scene::model::solid_model::edge_wires(body); + } + let Some(mesh) = scene.meshes.get(&h).or_else(|| scene.block_meshes.get(&h)) else { + return Vec::new(); + }; + mesh.edge_verts + .chunks_exact(2) + .enumerate() + .map(|(index, points)| { + let first_low = mesh + .edge_verts_low + .get(index * 2) + .copied() + .unwrap_or([0.0; 3]); + let second_low = mesh + .edge_verts_low + .get(index * 2 + 1) + .copied() + .unwrap_or([0.0; 3]); + acadrust::entities::Wire::from_points(vec![ + acadrust::types::Vector3::new( + points[0][0] as f64 + first_low[0] as f64, + points[0][1] as f64 + first_low[1] as f64, + points[0][2] as f64 + first_low[2] as f64, + ), + acadrust::types::Vector3::new( + points[1][0] as f64 + second_low[0] as f64, + points[1][1] as f64 + second_low[1] as f64, + points[1][2] as f64 + second_low[2] as f64, + ), + ]) + }) + .collect() + }); + if let Some(EntityType::Solid3D(solid)) = scene.document.get_entity_mut(h) { + if let Some(sat) = sat { + solid.set_sat_document(&sat); + } + if let Some(wires) = wires.filter(|wires| !wires.is_empty()) { + solid.wires = wires; } } } @@ -1408,14 +1444,23 @@ pub(super) fn on_open_file(&mut self) -> Task { move |result| Message::ObjImportFinished(tab_id, path, result),) } + fn sync_view_state_for_save(&mut self, i: usize) { + self.sync_vport_display(i); + if self.tabs[i].active_block_edit.is_none() { + self.tabs[i].scene.sync_camera_to_document(); + self.tabs[i].last_synced_camera_gen = + self.tabs[i].scene.camera_generation; + } + } + #[cfg(not(target_arch = "wasm32"))] pub(in crate::app) fn prepare_native_save(&mut self, i: usize) { - self.sync_vport_display(i); + self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); self.tabs[i].scene.document.header.user_real1 = self.tabs[i].scene.annotation_scale as f64; - self.sync_solid_models_to_acis(i); + self.sync_solid_models_for_save(i); } #[cfg(not(target_arch = "wasm32"))] @@ -1470,16 +1515,120 @@ pub(super) fn on_open_file(&mut self) -> Task { } #[cfg(target_arch = "wasm32")] - fn stamp_thumbnail(&mut self, i: usize, version: acadrust::DxfVersion) { - let scene = &self.tabs[i].scene; - let preview = crate::io::thumbnail::from_snapshot( - &scene.entity_wires(), - &scene.camera.borrow(), - scene.bg_color, - version >= acadrust::DxfVersion::AC1027, - self.vp_size, - ); - self.tabs[i].scene.document.preview = preview; + pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task { + let Some(pending) = self.pending_web_thumbnail_save.take() else { + return Task::none(); + }; + iced::window::latest() + .then(|window| match window { + Some(window) => iced::window::screenshot(window).map(Some), + None => Task::done(None), + }) + .map(move |screenshot| Message::WebSaveScreenshot { + tab_id: pending.tab_id, + filename: pending.filename.clone(), + ext: pending.ext.clone(), + version: pending.version, + bounds: Some(pending.bounds), + screenshot, + }) + } + + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn on_thumbnail_capture_frame(&mut self) -> Task { + let Some(pending) = self.pending_native_thumbnail_save.take() else { + return Task::none(); + }; + let Some(i) = self.tabs.iter().position(|tab| tab.id == pending.tab_id) else { + self.thumbnail_capture_clean = false; + return Task::none(); + }; + self.queue_native_save( + i, + pending.path, + pending.version, + pending.purpose, + pending.continuation, + pending.set_current_path, + pending.check_external_change, + ) + } + + #[cfg(target_arch = "wasm32")] + pub(super) fn on_web_save_screenshot( + &mut self, + tab_id: u64, + filename: String, + ext: String, + version: acadrust::DxfVersion, + bounds: Option, + screenshot: Option, + ) -> Task { + self.thumbnail_capture_clean = false; + let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else { + return Task::none(); + }; + let preview = screenshot.as_ref().and_then(|screenshot| { + bounds.and_then(|bounds| { + crate::io::thumbnail::from_screenshot( + screenshot, + bounds, + version >= acadrust::DxfVersion::AC1027, + ) + }) + }); + if let Some(preview) = preview { + self.tabs[i].scene.document.preview = Some(preview); + } + + let mut recent_task = Task::none(); + let saved = match crate::io::save_to_bytes( + &self.tabs[i].scene.document, + &ext, + version, + ) { + Ok(bytes) => { + crate::sys::download_bytes(&filename, &bytes); + let cache_name = std::path::Path::new(&filename) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| filename.clone()); + let path = std::path::PathBuf::from(cache_name); + self.tabs[i].current_path = Some(path.clone()); + self.tabs[i].scene.document.version = version; + self.tabs[i].dirty = false; + self.tabs[i].recovery_save_as_required = false; + recent_task = Task::perform( + async move { + crate::io::web_recent::store(&path.to_string_lossy(), &bytes) + .await + .map(|_| path) + }, + Message::WebRecentStored, + ); + self.command_line + .push_output(crate::tf!("Saved: {filename}").as_ref()); + true + } + Err(error) => { + self.command_line + .push_error(crate::tf!("Save failed: {error}").as_ref()); + false + } + }; + if self.save_dialog_for_unsaved { + if saved { + if let Some(crate::app::PendingClose::Tab(index)) = self.pending_close.take() { + let continuation = self.update(Message::TabClose(index)); + let rest = self.continue_tab_close_queue(); + return Task::batch([recent_task, continuation, rest]); + } + } else if self.pending_close.is_some() { + let retry = self.open_unsaved_dialog_window(); + return Task::batch([recent_task, retry]); + } + } + recent_task } #[cfg(not(target_arch = "wasm32"))] @@ -1494,11 +1643,27 @@ pub(super) fn on_open_file(&mut self) -> Task { check_external_change: bool, ) -> Task { let tab_id = self.tabs[i].id; + if self + .pending_native_thumbnail_save + .as_ref() + .is_some_and(|pending| pending.tab_id == tab_id) + { + if purpose != crate::app::SavePurpose::Autosave { + self.command_line + .push_info(crate::t!("Save already running for this drawing.").as_ref()); + } + return Task::none(); + } + let capture_ready = + self.thumbnail_capture_clean && self.pending_native_thumbnail_save.is_none(); if self.active_save_jobs.contains_key(&tab_id) { if purpose != crate::app::SavePurpose::Autosave { self.command_line .push_info(crate::t!("Save already running for this drawing.").as_ref()); } + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::none(); } let destination_is_current = self.tabs[i] @@ -1516,6 +1681,9 @@ pub(super) fn on_open_file(&mut self) -> Task { self.active_tab = i; self.save_dialog_for_unsaved = continuation != crate::app::SaveContinuation::None; + if capture_ready { + self.thumbnail_capture_clean = false; + } return self.open_save_dialog_window(i); } if purpose != crate::app::SavePurpose::Autosave @@ -1540,6 +1708,33 @@ pub(super) fn on_open_file(&mut self) -> Task { }); self.restore_failed_save_continuation(continuation, i); self.active_modal = Some(crate::app::ModalKind::FileInUse); + if capture_ready { + self.thumbnail_capture_clean = false; + } + return Task::none(); + } + + if purpose != crate::app::SavePurpose::Autosave + && i == self.active_tab + && !self.thumbnail_capture_clean + && self.main_window.is_some() + && crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) + .is_some() + { + self.pending_native_thumbnail_save = Some( + crate::app::PendingNativeThumbnailSave { + tab_id, + path, + version, + purpose, + continuation, + set_current_path, + check_external_change, + }, + ); + self.thumbnail_capture_clean = true; return Task::none(); } @@ -1566,6 +1761,9 @@ pub(super) fn on_open_file(&mut self) -> Task { }); self.restore_failed_save_continuation(continuation, i); self.active_modal = Some(crate::app::ModalKind::FileInUse); + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::none(); } Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => { @@ -1579,32 +1777,15 @@ pub(super) fn on_open_file(&mut self) -> Task { let epoch = self.tabs[i].scene.geometry_epoch; let revision = self.tabs[i].edit_revision; let camera_generation = self.tabs[i].scene.camera_generation; - let thumbnail_key = (purpose != crate::app::SavePurpose::Autosave).then(|| { - let scene = &self.tabs[i].scene; - crate::app::ThumbnailCacheKey { - epoch, - camera_generation, - bg_color: scene.bg_color.map(f32::to_bits), - png: version >= acadrust::DxfVersion::AC1027, - viewport: [self.vp_size.0.to_bits(), self.vp_size.1.to_bits()], - } + let thumbnail = (purpose != crate::app::SavePurpose::Autosave && i == self.active_tab) + .then_some(version >= acadrust::DxfVersion::AC1027); + let capture_bounds = thumbnail.and_then(|_| { + crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ) }); - let thumbnail = if thumbnail_key == self.tabs[i].thumbnail_cache_key { - None - } else { - thumbnail_key.map(|key| { - let scene = &self.tabs[i].scene; - ( - scene.entity_wires(), - scene.camera.borrow().clone(), - scene.bg_color, - key.png, - self.vp_size, - ) - }) - }; let clone_started = iced::time::Instant::now(); - let mut snapshot = self.tabs[i].scene.document.clone(); + let snapshot = self.tabs[i].scene.document.clone(); let clone_ms = clone_started.elapsed().as_secs_f64() * 1000.0; if crate::perf::enabled() { crate::perf_record!( @@ -1650,6 +1831,9 @@ pub(super) fn on_open_file(&mut self) -> Task { let (expected_fingerprint, verify_reader) = match verification { Ok(verification) => verification, Err(error) => { + if capture_ready { + self.thumbnail_capture_clean = false; + } return Task::perform( async move { crate::app::SaveOutcome { @@ -1664,7 +1848,6 @@ pub(super) fn on_open_file(&mut self) -> Task { set_current_path, purpose, continuation, - thumbnail_key, refreshed_preview: None, result: Err(error), } @@ -1674,65 +1857,97 @@ pub(super) fn on_open_file(&mut self) -> Task { } }; let worker_path = path.clone(); - - Task::perform( - async move { - let (result, refreshed_preview) = std::thread::spawn(move || { - let mut refreshed_preview = None; - if let Some((wires, camera, bg_color, png, viewport)) = thumbnail { - let started = iced::time::Instant::now(); - snapshot.preview = crate::io::thumbnail::from_snapshot( - &wires, - &camera, - bg_color, - png, - viewport, - ); - if crate::perf::enabled() { - crate::perf_record!( - "[perf] save-thumbnail {:.1}ms wires={}", - started.elapsed().as_secs_f64() * 1000.0, - wires.len(), - ); + let capture_window = thumbnail.and(capture_bounds).and(self.main_window); + let mut work = Some(( + snapshot, + thumbnail, + capture_bounds, + worker_path, + expected_fingerprint, + verify_reader, + path, + previous_autosave, + )); + let mut run_save = move |screenshot: Option| { + let ( + mut snapshot, + thumbnail, + capture_bounds, + worker_path, + expected_fingerprint, + verify_reader, + path, + previous_autosave, + ) = work.take().expect("save capture produced more than one result"); + Task::perform( + async move { + let (result, refreshed_preview) = std::thread::spawn(move || { + let mut refreshed_preview = None; + if let Some(png) = thumbnail { + let started = iced::time::Instant::now(); + if let Some(preview) = screenshot.as_ref().and_then(|screenshot| { + capture_bounds.and_then(|bounds| { + crate::io::thumbnail::from_screenshot(screenshot, bounds, png) + }) + }) { + snapshot.preview = Some(preview); + refreshed_preview = Some(snapshot.preview.clone()); + } + if crate::perf::enabled() { + crate::perf_record!( + "[perf] save-thumbnail {:.1}ms", + started.elapsed().as_secs_f64() * 1000.0, + ); + } } - refreshed_preview = Some(snapshot.preview.clone()); - } - let result = crate::io::save_owned_as_version_atomic( - snapshot, - &worker_path, + let result = crate::io::save_owned_as_version_atomic( + snapshot, + &worker_path, + version, + backup, + expected_fingerprint, + verify_reader, + ); + (result, refreshed_preview) + }) + .join() + .unwrap_or_else(|_| { + ( + Err(crate::io::SaveFailure::other("save worker panicked")), + None, + ) + }); + crate::app::SaveOutcome { + job_id, + tab_id, + epoch, + revision, + camera_generation, + path, version, - backup, - expected_fingerprint, - verify_reader, - ); - (result, refreshed_preview) - }) - .join() - .unwrap_or_else(|_| { - ( - Err(crate::io::SaveFailure::other("save worker panicked")), - None, - ) - }); - crate::app::SaveOutcome { - job_id, - tab_id, - epoch, - revision, - camera_generation, - path, - version, - previous_autosave, - set_current_path, - purpose, - continuation, - thumbnail_key, - refreshed_preview, - result, - } - }, - Message::SaveFinished, - ) + previous_autosave, + set_current_path, + purpose, + continuation, + refreshed_preview, + result, + } + }, + Message::SaveFinished, + ) + }; + match capture_window { + Some(window) => iced::window::screenshot(window).map(Some).then(move |screenshot| { + Task::batch([ + Task::done(Message::ThumbnailCaptureFinished), + run_save(screenshot), + ]) + }), + None => { + self.thumbnail_capture_clean = false; + run_save(None) + } + } } #[cfg(not(target_arch = "wasm32"))] @@ -1836,7 +2051,6 @@ pub(super) fn on_open_file(&mut self) -> Task { if let Some(preview) = outcome.refreshed_preview { self.tabs[i].scene.document.preview = preview; } - self.tabs[i].thumbnail_cache_key = outcome.thumbnail_key; } let mut tasks = Vec::new(); match outcome.purpose { @@ -1850,10 +2064,9 @@ pub(super) fn on_open_file(&mut self) -> Task { .as_deref() .is_none_or(|current| { !native_paths_match(current, &outcome.path) - }); + }); self.command_line .push_output(crate::tf!("Saved: {}", outcome.path.display()).as_ref()); - self.recent_thumbs.remove(&outcome.path); if let Some(previous) = outcome.previous_autosave { if previous != outcome.path { let _ = std::fs::remove_file(previous); @@ -1865,8 +2078,8 @@ pub(super) fn on_open_file(&mut self) -> Task { if outcome.purpose == crate::app::SavePurpose::SaveAs { self.tabs[i].recovery_save_as_required = false; } - tasks.push(self.push_recent(outcome.path.clone())); } + tasks.push(self.push_recent(outcome.path.clone())); self.refresh_native_edit_guard_after_save( i, &outcome.path, @@ -2064,7 +2277,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // once after the destination/version is known. #[cfg(target_arch = "wasm32")] { - self.sync_vport_display(i); + self.sync_view_state_for_save(i); self.stamp_header_sysvars(i); } // Native: save straight to the known path. Web has no path @@ -2199,62 +2412,38 @@ pub(super) fn on_open_file(&mut self) -> Task { #[cfg(target_arch = "wasm32")] { let close = self.close_save_dialog_window(); + self.sync_view_state_for_save(i); sync_annotation_scale_header(&mut self.tabs[i].scene); self.stamp_header_sysvars(i); - self.sync_solid_models_to_acis(i); - self.stamp_thumbnail(i, version); - let mut recent_task = Task::none(); - let saved = match crate::io::save_to_bytes( - &self.tabs[i].scene.document, - ext, - version, - ) { - Ok(bytes) => { - crate::sys::download_bytes(&filename, &bytes); - let cache_name = std::path::Path::new(&filename) - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| filename.clone()); - let path = std::path::PathBuf::from(cache_name); - self.tabs[i].current_path = Some(path.clone()); - self.tabs[i].scene.document.version = version; - self.tabs[i].dirty = false; - self.tabs[i].recovery_save_as_required = false; - recent_task = Task::perform( - async move { - crate::io::web_recent::store( - &path.to_string_lossy(), - &bytes, - ) - .await - .map(|_| path) - }, - Message::WebRecentStored, - ); - self.command_line.push_output(crate::tf!("Saved: {filename}").as_ref()); - true - } - Err(e) => { - self.command_line.push_error(crate::tf!("Save failed: {e}").as_ref()); - false - } + self.sync_solid_models_for_save(i); + let tab_id = self.tabs[i].id; + let bounds = crate::ui::wrap_bar::dropdown_bounds( + crate::app::view::VIEWPORT_CAPTURE_BOUNDS_ID, + ); + let Some(bounds) = bounds else { + return Task::batch([ + close, + Task::done(Message::WebSaveScreenshot { + tab_id, + filename, + ext: ext.to_string(), + version, + bounds: None, + screenshot: None, + }), + ]); }; - // Continue a pending tab close. - if self.save_dialog_for_unsaved { - if saved { - if let Some(crate::app::PendingClose::Tab(idx)) = - self.pending_close.take() - { - let cont = self.update(Message::TabClose(idx)); - let rest = self.continue_tab_close_queue(); - return Task::batch([close, recent_task, cont, rest]); - } - } else if self.pending_close.is_some() { - let retry = self.open_unsaved_dialog_window(); - return Task::batch([close, recent_task, retry]); - } - } - Task::batch([close, recent_task]) + self.pending_web_thumbnail_save = Some( + crate::app::PendingWebThumbnailSave { + tab_id, + filename, + ext: ext.to_string(), + version, + bounds, + }, + ); + self.thumbnail_capture_clean = true; + close } } @@ -2365,6 +2554,7 @@ pub(super) fn on_open_file(&mut self) -> Task { { continue; } + self.prepare_native_save(i); let version = self.tabs[i].scene.document.version; let target = self.autosave_target(i); tasks.push(self.queue_native_save( diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e5fb0be1..66ed2b60 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6320,9 +6320,33 @@ impl OpenCADStudio { Message::AutoSave => self.on_autosave(), + Message::ThumbnailCaptureFrame => self.on_thumbnail_capture_frame(), + + Message::ThumbnailCaptureFinished => { + self.thumbnail_capture_clean = false; + Task::none() + } + #[cfg(not(target_arch = "wasm32"))] Message::SaveFinished(outcome) => self.on_save_finished(outcome), + #[cfg(target_arch = "wasm32")] + Message::WebSaveScreenshot { + tab_id, + filename, + ext, + version, + bounds, + screenshot, + } => self.on_web_save_screenshot( + tab_id, + filename, + ext, + version, + bounds, + screenshot, + ), + #[cfg(not(target_arch = "wasm32"))] Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(), diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 35bcecbd..2409b6a2 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -809,6 +809,7 @@ impl OpenCADStudio { // render mode; the model-layout tab style is untouched. if self.tabs[i].scene.set_active_viewport_render_mode(mode) { self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Viewport visual style: {label}").as_ref()); return Task::none(); @@ -827,6 +828,7 @@ impl OpenCADStudio { // Re-upload face3d fills on the next frame — the render // pipeline keys its upload cache off `geometry_epoch`. self.tabs[i].scene.bump_geometry_no_blocks(); + self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("Visual style: {label}").as_ref()); Task::none() diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index bfbce428..a9c104fa 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -32,6 +32,8 @@ use viewcube::{viewcube_nav_controls, viewcube_ucs_picker, UCS_PICKER_W}; // the `view::` path as before the split. pub(in crate::app) use overlay::{MTEXT_TEXT_ID, TEXT_INLINE_ID}; +pub(in crate::app) const VIEWPORT_CAPTURE_BOUNDS_ID: &str = "viewport-capture-bounds"; + const VIEWCUBE_HIT_SIZE: f32 = VIEWCUBE_REGION_PX; /// The desk shown around the sheet, as the widget wants it. One definition, /// shared with the renderer that clears the sheet viewport to the same thing. @@ -184,6 +186,7 @@ impl OpenCADStudio { pub fn view_main(&self) -> Element<'_, Message> { let i = self.active_tab; let tab = &self.tabs[i]; + let thumbnail_capture_clean = self.thumbnail_capture_clean; let theme_text = self.active_theme.palette().background.base.text; let viewcube_text_color = [ theme_text.r, @@ -229,8 +232,10 @@ impl OpenCADStudio { let (vw, vh) = tab.scene.selection.borrow().vp_size; tab.scene.active_model_tile_bounds(vw, vh).width }; - let viewcube_visible = - self.show_viewcube && !tab.is_start && viewcube_has_room(render_bar_w, active_vp_w); + let viewcube_visible = self.show_viewcube + && !thumbnail_capture_clean + && !tab.is_start + && viewcube_has_room(render_bar_w, active_vp_w); // Start tab: render welcome page in place of the viewport. // Surrounding chrome (tab bar, status bar) stays; the welcome widget // returned here also flags the rest of `view` to skip drawing-only @@ -258,6 +263,7 @@ impl OpenCADStudio { shader(ViewportPane::model( &tab.scene, viewcube_visible, + !thumbnail_capture_clean, viewport_render_mode, viewcube_text_color, )) @@ -276,6 +282,7 @@ impl OpenCADStudio { // mouse_areas' hover state and drops their move events). let scene = &tab.scene; let show_viewcube = viewcube_visible; + let show_interaction = !thumbnail_capture_clean; let render_mode = viewport_render_mode; let size_probe: Element<'_, Message> = responsive(move |size| { { @@ -293,6 +300,7 @@ impl OpenCADStudio { shader(ViewportPane::for_pane( scene, show_viewcube, + show_interaction, render_mode, idx, viewcube_text_color, @@ -868,6 +876,20 @@ impl OpenCADStudio { .height(Fill)] .width(Fill) .height(Fill) + } else if thumbnail_capture_clean { + let capture_bg = if is_paper { PAPER_SPACE_BACKGROUND } else { bg_color }; + stack![ + container(Space::new()) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(capture_bg)), + ..Default::default() + }) + .width(Fill) + .height(Fill), + viewport_3d, + ] + .width(Fill) + .height(Fill) } else if is_paper { // Keep the grid above the opaque GPU sheet and below interaction UI. stack![ @@ -902,6 +924,7 @@ impl OpenCADStudio { .height(Fill) }; + if !thumbnail_capture_clean { // Per-pane input pane_grid goes ABOVE the crosshair overlay so it // receives mouse events (the overlay's `Hidden` cursor would otherwise // starve any layer beneath it). The controls bar is pushed on top of it. @@ -1481,6 +1504,7 @@ impl OpenCADStudio { viewport_stack = viewport_stack.push(text_inline_overlay(ed, canvas)); } } + } // Docked side panels (Properties, block palette, future palettes) live // in an ordered vertical stack on the left/right edge of the drawing @@ -1535,7 +1559,13 @@ impl OpenCADStudio { if let Some(e) = left_edge { parts.push(e); } - parts.push(viewport_stack.into()); + parts.push( + crate::ui::wrap_bar::PosReport::new( + VIEWPORT_CAPTURE_BOUNDS_ID, + viewport_stack, + ) + .into(), + ); if let Some(e) = right_edge { parts.push(e); } @@ -1712,7 +1742,9 @@ impl OpenCADStudio { &self.history_content, self.win_size.1, ); - let center_stack: Element<'_, Message> = if tab.is_start { + let center_stack: Element<'_, Message> = if thumbnail_capture_clean { + workspace + } else if tab.is_start { column![ workspace, iced::widget::container(command_line) @@ -2096,6 +2128,11 @@ impl OpenCADStudio { } else { Subscription::none() }; + let thumbnail_capture = if self.thumbnail_capture_clean { + window::frames().map(|_| Message::ThumbnailCaptureFrame) + } else { + Subscription::none() + }; // Blink the MText preview caret while the editor is open. let caret_blink = if self.mtext_editor.is_some() { iced::time::every(std::time::Duration::from_millis(530)) @@ -2154,6 +2191,7 @@ impl OpenCADStudio { grip_dwell, hover_dwell, nav_settle, + thumbnail_capture, caret_blink, web_fonts, autosave, diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs index 8b485053..28272b40 100644 --- a/src/io/thumbnail.rs +++ b/src/io/thumbnail.rs @@ -1,9 +1,7 @@ //! DWG preview thumbnails. //! -//! - [`from_snapshot`] rasterizes the drawing exactly as it is framed on screen — -//! the live camera's pan / zoom / rotation, only the currently visible region, -//! not the whole extent — into a small [`acadrust::Preview`] embedded on save -//! so OCS drawings show a thumbnail in file browsers and other CAD apps. +//! - [`from_screenshot`] crops the visible drawing area into a small +//! [`acadrust::Preview`] embedded on save. //! - [`read_handle`] / [`extract_to_png`] read a DWG's *embedded* preview back //! for the Start page and the OS file-manager thumbnailer. Extraction lives in //! the shared [`dwg_thumbnailer`] core crate (also used by the Windows/macOS @@ -11,67 +9,55 @@ use acadrust::{Preview, PreviewFormat}; use iced::Rectangle; -use image::{ImageFormat, Rgb, RgbImage}; +use image::{ImageFormat, RgbImage}; use std::io::Cursor; -use crate::scene::WireModel; -use crate::scene::view::camera::Camera; +/// Build a preview from the visible drawing area of an Iced window screenshot. +pub fn from_screenshot( + screenshot: &iced::window::Screenshot, + bounds: Rectangle, + png: bool, +) -> Option { + let scale = screenshot.scale_factor; + if !scale.is_finite() || scale <= 0.0 || bounds.width <= 0.0 || bounds.height <= 0.0 { + return None; + } + + let width = screenshot.size.width as f32; + let height = screenshot.size.height as f32; + let left = (bounds.x * scale).floor().clamp(0.0, width) as u32; + let top = (bounds.y * scale).floor().clamp(0.0, height) as u32; + let right = ((bounds.x + bounds.width) * scale) + .ceil() + .clamp(0.0, width) as u32; + let bottom = ((bounds.y + bounds.height) * scale) + .ceil() + .clamp(0.0, height) as u32; + if right <= left || bottom <= top { + return None; + } + + let cropped = screenshot + .crop(Rectangle { + x: left, + y: top, + width: right - left, + height: bottom - top, + }) + .ok()?; + let rgba = image::RgbaImage::from_raw( + cropped.size.width, + cropped.size.height, + cropped.rgba.to_vec(), + )?; + let (cw, ch) = canvas_dims(cropped.size.width as f64 / cropped.size.height as f64); + let resized = image::imageops::resize(&rgba, cw, ch, image::imageops::FilterType::Triangle); + encode(image::DynamicImage::ImageRgba8(resized).into_rgb8(), png) +} /// Longest edge of the generated thumbnail, in pixels. const MAX_DIM: u32 = 256; -/// Build a preview matching what is currently on screen: the drawing's wires -/// projected through the live camera into a `viewport`-aspect canvas, so the -/// thumbnail is the visible framing (pan / zoom / rotation, only the on-screen -/// region), not the whole extent. `viewport` is the model pane's pixel size. -/// `None` when the drawing is empty (clears any stale preview). -/// -/// `png` picks the encoding: a line drawing on a flat background is almost all -/// one colour, so a **PNG** collapses to a few KB where the uncompressed -/// **BMP/DIB** stays ~180 KB at 256². PNG previews are only valid from R2013 -/// (AC1027) on, so the caller passes `false` for older targets → BMP/DIB. -/// -/// Native background saves retain -/// the resident wire `Arc` and a small camera copy, then run the point scan and -/// image encoding on the save worker instead of blocking the UI thread. -pub fn from_snapshot( - wires: &[WireModel], - camera: &Camera, - bg_color: [f32; 4], - png: bool, - viewport: (f32, f32), -) -> Option { - if wires.is_empty() { - return None; - } - let (vw, vh) = viewport; - if !(vw > 0.0 && vh > 0.0) { - return None; - } - - // Canvas keeps the viewport's aspect so the framing is undistorted; longest - // edge = MAX_DIM. Projecting with the canvas rectangle as the camera bounds - // makes `project` return pixel coordinates already in canvas space. - let (cw, ch) = canvas_dims((vw / vh) as f64); - let bounds = Rectangle { - x: 0.0, - y: 0.0, - width: cw as f32, - height: ch as f32, - }; - #[cfg(not(target_arch = "wasm32"))] - { - rasterize_snapshot_parallel(wires, camera, bounds, cw, ch, bg_color, png) - } - #[cfg(target_arch = "wasm32")] - { - rasterize(wires, cw, ch, bg_color, png, |x, y, z| { - camera.project(glam::DVec3::new(x, y, z), bounds) - .map(|s| (s.x.round() as i32, s.y.round() as i32)) - }) - } -} - /// Canvas dimensions for an aspect ratio, longest edge = [`MAX_DIM`]. fn canvas_dims(aspect: f64) -> (u32, u32) { if aspect >= 1.0 { @@ -81,184 +67,7 @@ fn canvas_dims(aspect: f64) -> (u32, u32) { } } -#[cfg(not(target_arch = "wasm32"))] -fn rasterize_snapshot_parallel( - wires: &[WireModel], - camera: &Camera, - bounds: Rectangle, - cw: u32, - ch: u32, - bg: [f32; 4], - png: bool, -) -> Option { - use crate::par::prelude::*; - - let total_segments: usize = wires - .iter() - .map(|wire| wire.points.len().saturating_sub(1)) - .sum(); - if total_segments < 100_000 { - return rasterize(wires, cw, ch, bg, png, |x, y, z| { - camera.project(glam::DVec3::new(x, y, z), bounds) - .map(|point| (point.x.round() as i32, point.y.round() as i32)) - }); - } - - let task_count = (rayon::current_num_threads().max(1) * 2).min(total_segments); - let target_work = total_segments.div_ceil(task_count).max(1); - let mut tasks: Vec> = Vec::with_capacity(task_count); - let mut task = Vec::new(); - let mut task_work = 0usize; - for (wire_index, wire) in wires.iter().enumerate() { - let segment_count = wire.points.len().saturating_sub(1); - let mut start = 0usize; - while start < segment_count { - let take = (target_work - task_work).min(segment_count - start); - task.push((wire_index, start, start + take)); - task_work += take; - start += take; - if task_work == target_work { - tasks.push(std::mem::take(&mut task)); - task_work = 0; - } - } - } - if !task.is_empty() { - tasks.push(task); - } - - let layers: Vec> = tasks - .par_iter() - .map(|task| { - let mut pixels = vec![u32::MAX; (cw * ch) as usize]; - for &(wire_index, start, end) in task { - let wire = &wires[wire_index]; - let color = to_rgb(wire.color); - let packed = - color[0] as u32 | (color[1] as u32) << 8 | (color[2] as u32) << 16; - let mut previous = projected_wire_point(wire, start, camera, bounds); - for index in start + 1..=end { - let current = projected_wire_point(wire, index, camera, bounds); - if let (Some(a), Some(b)) = (previous, current) { - draw_line_layer(&mut pixels, cw, ch, a, b, packed); - } - previous = current; - } - } - pixels - }) - .collect(); - - let mut image = RgbImage::from_pixel(cw, ch, Rgb(to_rgb(bg))); - for layer in layers { - for (pixel, packed) in image.pixels_mut().zip(layer) { - if packed != u32::MAX { - *pixel = Rgb([ - packed as u8, - (packed >> 8) as u8, - (packed >> 16) as u8, - ]); - } - } - } - encode(image, png) -} - -#[cfg(not(target_arch = "wasm32"))] -fn projected_wire_point( - wire: &WireModel, - index: usize, - camera: &Camera, - bounds: Rectangle, -) -> Option<(i32, i32)> { - let point = wire.points.get(index)?; - if !point[0].is_finite() || !point[1].is_finite() { - return None; - } - let (x, y, z) = abs_xyz(wire, index, point); - camera - .project(glam::DVec3::new(x, y, z), bounds) - .map(|screen| (screen.x.round() as i32, screen.y.round() as i32)) -} - -#[cfg(not(target_arch = "wasm32"))] -fn draw_line_layer( - pixels: &mut [u32], - width: u32, - height: u32, - (x0, y0): (i32, i32), - (x1, y1): (i32, i32), - color: u32, -) { - let (width, height) = (width as i32, height as i32); - let Some(((mut x0, mut y0), (x1, y1))) = - clip_line_to_rect((x0, y0), (x1, y1), width, height) - else { - return; - }; - let dx = (x1 - x0).abs(); - let dy = -(y1 - y0).abs(); - let sx = if x0 < x1 { 1 } else { -1 }; - let sy = if y0 < y1 { 1 } else { -1 }; - let mut error = dx + dy; - loop { - if x0 >= 0 && x0 < width && y0 >= 0 && y0 < height { - pixels[y0 as usize * width as usize + x0 as usize] = color; - } - if x0 == x1 && y0 == y1 { - break; - } - let twice = 2 * error; - if twice >= dy { - error += dy; - x0 += sx; - } - if twice <= dx { - error += dx; - y0 += sy; - } - } -} - -/// Rasterize `wires` onto a `bg`-filled `cw`×`ch` canvas, placing each vertex -/// with `project` (world XYZ → canvas pixel, `None` = not projectable), and -/// encode the result. A `None` from `project` breaks the polyline run, as does a -/// NaN separator, so off-screen / clipped segments simply don't draw. -fn rasterize( - wires: &[WireModel], - cw: u32, - ch: u32, - bg: [f32; 4], - png: bool, - project: impl Fn(f64, f64, f64) -> Option<(i32, i32)>, -) -> Option { - let mut img = RgbImage::from_pixel(cw, ch, Rgb(to_rgb(bg))); - for w in wires { - let col = Rgb(to_rgb(w.color)); - let mut prev: Option<(i32, i32)> = None; - for (i, p) in w.points.iter().enumerate() { - if !p[0].is_finite() || !p[1].is_finite() { - prev = None; // NaN separator breaks the run - continue; - } - let (x, y, z) = abs_xyz(w, i, p); - let cur = project(x, y, z); - if let (Some(a), Some(b)) = (prev, cur) { - draw_line(&mut img, a, b, col); - } - prev = cur; - } - } - encode(img, png) -} - -/// Encode the canvas. PNG for R2013+ targets (few KB); else a BMP → DIB (no -/// 14-byte BITMAPFILEHEADER, which the DWG preview container doesn't carry). -/// The BMP is an 8-bit **RLE8**-compressed DIB — a line drawing on a flat -/// background is a few distinct colours with long single-colour runs, so it -/// collapses from the ~180 KB of a 24-bit DIB to a handful of KB. A view with -/// more than 256 distinct colours (rare) can't be palettised, so it falls back -/// to the 24-bit uncompressed DIB. +/// Encode PNG for R2013+; older targets receive a BMP/DIB. fn encode(img: RgbImage, png: bool) -> Option { if png { let mut buf = Cursor::new(Vec::new()); @@ -347,25 +156,6 @@ fn bmp24_dib(img: &RgbImage) -> Option> { (bmp.len() > 14).then(|| bmp[14..].to_vec()) } -/// Absolute world XYZ of vertex `i`, reconstructing the double-single residual. -#[inline] -fn abs_xyz(w: &WireModel, i: usize, p: &[f32; 3]) -> (f64, f64, f64) { - let (lx, ly, lz) = w - .points_low - .get(i) - .map_or((0.0, 0.0, 0.0), |l| (l[0] as f64, l[1] as f64, l[2] as f64)); - (p[0] as f64 + lx, p[1] as f64 + ly, p[2] as f64 + lz) -} - -#[inline] -fn to_rgb(c: [f32; 4]) -> [u8; 3] { - [ - (c[0].clamp(0.0, 1.0) * 255.0).round() as u8, - (c[1].clamp(0.0, 1.0) * 255.0).round() as u8, - (c[2].clamp(0.0, 1.0) * 255.0).round() as u8, - ] -} - /// Read a DWG's embedded preview and write it as a PNG at `output`, scaled so /// its longest edge is at most `size`. Returns `false` on any failure (no /// preview, undecodable, write error) so the OS thumbnailer falls back to a @@ -399,14 +189,6 @@ pub fn read_handle(path: &std::path::Path) -> Option WireModel { - WireModel { - points: pts.iter().map(|&[x, y]| [x, y, 0.0]).collect(), - color, - ..Default::default() - } - } - /// Prepend a `BITMAPFILEHEADER` so `image` can decode the DIB. Mirrors the /// palette-aware offset the shared `dwg_thumbnailer::dib_to_bmp` computes. fn dib_to_bmp(dib: &[u8]) -> Vec { @@ -429,35 +211,13 @@ mod tests { assert_eq!(canvas_dims(0.5), (MAX_DIM / 2, MAX_DIM)); // tall } - #[test] - fn rasterize_draws_a_valid_non_blank_dib() { - let bg = [0.1, 0.1, 0.1, 1.0]; - // A closed square (connected polyline) in a distinct colour. - let sq = wire( - &[[10.0, 10.0], [90.0, 10.0], [90.0, 90.0], [10.0, 90.0], [10.0, 10.0]], - [1.0, 0.0, 0.0, 1.0], - ); - // Trivial projector: world XY straight to pixels (Y flipped), z ignored. - let p = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, false, |x, y, _| { - Some((x.round() as i32, (MAX_DIM as f64 - y).round() as i32)) - }) - .expect("some preview"); - assert_eq!(p.format, PreviewFormat::Bmp); - // DIB starts with a 40-byte BITMAPINFOHEADER. - assert_eq!(&p.data[0..4], &40u32.to_le_bytes()); - let img = image::load_from_memory(&dib_to_bmp(&p.data)).expect("decodes").to_rgb8(); - assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM)); - let bg_px = to_rgb(bg); - assert!(img.pixels().any(|px| px.0 != bg_px), "nothing drawn"); - assert!(img.pixels().any(|px| px.0[0] > 128 && px.0[1] < 64), "square not red"); - } - #[test] fn rle8_bmp_is_8bit_compressed_and_round_trips() { - let bg = [1.0, 1.0, 1.0, 1.0]; - let sq = wire(&[[10.0, 10.0], [90.0, 90.0]], [0.0, 0.0, 0.0, 1.0]); - let proj = |x: f64, y: f64, _z: f64| Some((x.round() as i32, y.round() as i32)); - let bmp = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, false, proj).unwrap(); + let mut image = RgbImage::from_pixel(MAX_DIM, MAX_DIM, image::Rgb([255, 255, 255])); + for i in 10..90 { + image.put_pixel(i, i, image::Rgb([0, 0, 0])); + } + let bmp = encode(image, false).unwrap(); assert_eq!(bmp.format, PreviewFormat::Bmp); // 8-bit, BI_RLE8. assert_eq!(u16::from_le_bytes([bmp.data[14], bmp.data[15]]), 8, "bitcount"); @@ -476,90 +236,10 @@ mod tests { #[test] fn png_preview_decodes() { - let bg = [1.0, 1.0, 1.0, 1.0]; - let sq = wire(&[[10.0, 10.0], [90.0, 90.0]], [0.0, 0.0, 0.0, 1.0]); - let p = rasterize(&[sq], MAX_DIM, MAX_DIM, bg, true, |x, y, _| { - Some((x.round() as i32, y.round() as i32)) - }) - .unwrap(); + let image = RgbImage::from_pixel(MAX_DIM, MAX_DIM, image::Rgb([255, 255, 255])); + let p = encode(image, true).unwrap(); assert_eq!(p.format, PreviewFormat::Png); let img = image::load_from_memory_with_format(&p.data, ImageFormat::Png).expect("png decodes"); assert_eq!((img.width(), img.height()), (MAX_DIM, MAX_DIM)); } } - -/// Bresenham line, clipped to the image bounds. -fn draw_line(img: &mut RgbImage, (x0, y0): (i32, i32), (x1, y1): (i32, i32), col: Rgb) { - let (w, h) = (img.width() as i32, img.height() as i32); - let Some(((mut x0, mut y0), (x1, y1))) = - clip_line_to_rect((x0, y0), (x1, y1), w, h) - else { - return; - }; - let dx = (x1 - x0).abs(); - let dy = -(y1 - y0).abs(); - let sx = if x0 < x1 { 1 } else { -1 }; - let sy = if y0 < y1 { 1 } else { -1 }; - let mut err = dx + dy; - loop { - if x0 >= 0 && x0 < w && y0 >= 0 && y0 < h { - img.put_pixel(x0 as u32, y0 as u32, col); - } - if x0 == x1 && y0 == y1 { - break; - } - let e2 = 2 * err; - if e2 >= dy { - err += dy; - x0 += sx; - } - if e2 <= dx { - err += dx; - y0 += sy; - } - } -} - -fn clip_line_to_rect( - (x0, y0): (i32, i32), - (x1, y1): (i32, i32), - width: i32, - height: i32, -) -> Option<((i32, i32), (i32, i32))> { - if width <= 0 || height <= 0 { - return None; - } - let (x0, y0, x1, y1) = (x0 as f64, y0 as f64, x1 as f64, y1 as f64); - let (dx, dy) = (x1 - x0, y1 - y0); - let mut enter = 0.0f64; - let mut leave = 1.0f64; - for (p, q) in [ - (-dx, x0), - (dx, width.saturating_sub(1) as f64 - x0), - (-dy, y0), - (dy, height.saturating_sub(1) as f64 - y0), - ] { - if p == 0.0 { - if q < 0.0 { - return None; - } - continue; - } - let ratio = q / p; - if p < 0.0 { - enter = enter.max(ratio); - } else { - leave = leave.min(ratio); - } - if enter > leave { - return None; - } - } - let point = |t: f64| { - ( - (x0 + t * dx).round().clamp(0.0, width.saturating_sub(1) as f64) as i32, - (y0 + t * dy).round().clamp(0.0, height.saturating_sub(1) as f64) as i32, - ) - }; - Some((point(enter), point(leave))) -} diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index 75d15dcb..b27c6061 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -315,7 +315,9 @@ impl Scene { self.camera_generation = saved_generation; if let Some((min, max)) = refreshed { - self.camera.borrow_mut().fit_depth_to_bounds(min, max); + self.camera + .borrow_mut() + .fit_depth_to_bounds_f64(min, max); self.projection_bounds_epoch.set(self.geometry_epoch); } } @@ -672,11 +674,7 @@ impl Scene { // The saved-view convention uses a 24 mm vertical aperture. This is // the inverse of the projection path's documented // `distance = view_height * lens / 24` relation. - let fov_y = if perspective { - 2.0 * (12.0 / lens_length).atan() - } else { - 45.0_f32.to_radians() - }; + let fov_y = 2.0 * (12.0 / lens_length).atan(); let distance = ((view_height as f32 / 2.0) / (fov_y * 0.5).tan()).max(0.001); Some(Camera { target, @@ -729,9 +727,9 @@ impl Scene { let view_dir = cam.rotation * glam::Vec3::Z; let view_height = cam.ortho_size() * 2.0; let target_wcs = acadrust::types::Vector3 { - x: (cam.target.x as f64) + [0.0_f64; 3][0], - y: (cam.target.y as f64) + [0.0_f64; 3][1], - z: (cam.target.z as f64) + [0.0_f64; 3][2], + x: cam.target.x, + y: cam.target.y, + z: cam.target.z, }; entry.lower_left = lower_left; entry.upper_right = upper_right; @@ -1186,9 +1184,11 @@ impl Scene { if !ok { return; } - let min = glam::Vec3::new(lo.x as f32, lo.y as f32, lo.z as f32); - let max = glam::Vec3::new(hi.x as f32, hi.y as f32, hi.z as f32); - self.camera.borrow_mut().fit_depth_to_bounds(min, max); + let min = glam::DVec3::new(lo.x, lo.y, lo.z); + let max = glam::DVec3::new(hi.x, hi.y, hi.z); + self.camera + .borrow_mut() + .fit_depth_to_bounds_f64(min, max); } fn fit_paper_space_extents(&mut self) { @@ -1557,7 +1557,7 @@ impl Scene { if index == active { tile.camera = live_camera.clone(); } else { - tile.camera.fit_to_bounds(min, max, aspect); + tile.camera.fit_to_bounds_f64(min, max, aspect); } } if let Some(aspect) = aspects.get(active) { diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index d8c24af5..57792edd 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -42,6 +42,8 @@ pub(crate) fn body_transform( values.extend_from_slice(&components[..len]); } else if let Some(value) = token.as_float() { values.push(value); + } else if let Some(value) = token.as_integer() { + values.push(value as f64); } else if let Some(text) = token.as_string() { for word in text.split_ascii_whitespace() { let Ok(value) = word.parse::() else { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 6d92855f..9c638f1a 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -924,6 +924,7 @@ impl Scene { annotation_scale_handle: Option, all_visible: bool, viewport: Option, + tint_selected: bool, ) -> Vec { let layer_hidden = |layer: &str| { self.document @@ -1098,7 +1099,7 @@ impl Scene { } } } - if self.selected.contains(&handle) { + if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } let d = depth_map.get(&handle.value()).map_or(0.0, |d| d[0]); @@ -1115,7 +1116,7 @@ impl Scene { models.extend(self.instanced_hatch_models( target_block, hatch_bg, - true, + tint_selected, frozen, annotation_scale_handle, all_visible, diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 1f7e6f67..e141c814 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -5674,14 +5674,19 @@ impl Scene { arc } + #[cfg(test)] pub(super) fn hatch_models_arc(&self) -> Arc> { + self.hatch_models_arc_for_view(true) + } + + fn hatch_models_arc_for_view(&self, tint_selected: bool) -> Arc> { // Hatch models bake the selection tint (issue #71), so they depend on // the *selected set* — but NOT on hover. Keying on `selection_generation` // (which also bumps on every hover) made each hover-over a new entity // rebuild every hatch model: an O(N-hatch) stutter on hatch-heavy // drawings. Key on a signature of `selected` instead, so hover (which // never changes `selected`) keeps the cache warm. - let sel_sig = self.selected_hatch_sig(); + let sel_sig = if tint_selected { self.selected_hatch_sig() } else { 0 }; let target_block = self.content_render_block_handle(); let key = (target_block, self.current_layout.clone()); { @@ -5728,6 +5733,7 @@ impl Scene { scale, self.annotation_all_visible(), None, + tint_selected, )); self.hatch_cache.borrow_mut().insert( key, @@ -6324,9 +6330,10 @@ impl Scene { &self, viewport: Handle, frozen: &HashSet, + tint_selected: bool, ) -> Arc> { if viewport.is_null() && frozen.is_empty() { - return self.hatch_models_arc(); + return self.hatch_models_arc_for_view(tint_selected); } let target_block = self.content_render_block_handle(); let scale = self.viewport_scale_handle(viewport); @@ -6336,7 +6343,7 @@ impl Scene { ^ self.viewport_style_key(Some(viewport)).rotate_left(23); let sig = Self::frozen_layers_sig(frozen) ^ context_sig; let key = (target_block, self.current_layout.clone(), sig); - let sel = self.selected_hatch_sig(); + let sel = if tint_selected { self.selected_hatch_sig() } else { 0 }; if let Some((e, s, arc)) = self.frozen_hatch_cache.borrow().get(&key) { if *e == self.geometry_epoch && *s == sel { return Arc::clone(arc); @@ -6348,6 +6355,7 @@ impl Scene { scale, all_visible, Some(viewport), + tint_selected, )); self.frozen_hatch_cache .borrow_mut() diff --git a/src/scene/paper.rs b/src/scene/paper.rs index a3c11612..2ed40163 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -204,7 +204,18 @@ impl Scene { Arc>, Arc>, ) { - let selected = self.selected_hatch_sig(); + self.paper_sheet_render_models_for_view(true) + } + + pub(super) fn paper_sheet_render_models_for_view( + &self, + tint_selected: bool, + ) -> ( + Arc>, + Arc>, + Arc>, + ) { + let selected = if tint_selected { self.selected_hatch_sig() } else { 0 }; let reuse = { let cache = self.paper_sheet_render_cache.borrow(); if let Some(cache) = cache.get(&self.current_layout) { @@ -259,7 +270,11 @@ impl Scene { if let Some(sheet) = self.paper_sheet_fill() { hatches.push(sheet); } - hatches.extend(self.paper_canvas_hatches().iter().cloned()); + hatches.extend( + self.paper_canvas_hatches(tint_selected) + .iter() + .cloned(), + ); let hatches = Arc::new(hatches); let wipeouts = self.paper_canvas_wipeouts(); let images = self.paper_sheet_images(); @@ -381,7 +396,7 @@ impl Scene { /// entity handle) rather than the already-flattened arc — the /// flattened arc carries pattern names, not handles, so filtering /// there is unreliable. - pub fn paper_canvas_hatches(&self) -> Arc> { + fn paper_canvas_hatches(&self, tint_selected: bool) -> Arc> { let layout_block = self.current_layout_block_handle(); let layer_hidden = |layer: &str| { self.document @@ -455,7 +470,7 @@ impl Scene { } } } - if self.selected.contains(&handle) { + if tint_selected && self.selected.contains(&handle) { m.color = [0.15, 0.55, 1.00, m.color[3]]; } models.push(m); diff --git a/src/scene/view/camera.rs b/src/scene/view/camera.rs index 8fbbe1d1..48de4322 100644 --- a/src/scene/view/camera.rs +++ b/src/scene/view/camera.rs @@ -61,7 +61,7 @@ pub struct Camera { /// that span, clipping the drawing (#473). Re-projecting the box each frame /// keeps near/far exactly as deep as the current view needs. `None` falls /// back to `depth_half_range`. - pub model_bounds: Option<(Vec3, Vec3)>, + pub model_bounds: Option<(DVec3, DVec3)>, } impl Default for Camera { @@ -435,25 +435,24 @@ impl Camera { if let Some((min, max)) = self.model_bounds { let corners = [ - Vec3::new(min.x, min.y, min.z), - Vec3::new(max.x, min.y, min.z), - Vec3::new(min.x, max.y, min.z), - Vec3::new(max.x, max.y, min.z), - Vec3::new(min.x, min.y, max.z), - Vec3::new(max.x, min.y, max.z), - Vec3::new(min.x, max.y, max.z), - Vec3::new(max.x, max.y, max.z), + DVec3::new(min.x, min.y, min.z), + DVec3::new(max.x, min.y, min.z), + DVec3::new(min.x, max.y, min.z), + DVec3::new(max.x, max.y, min.z), + DVec3::new(min.x, min.y, max.z), + DVec3::new(max.x, min.y, max.z), + DVec3::new(min.x, max.y, max.z), + DVec3::new(max.x, max.y, max.z), ]; - let mut new_min = Vec3::splat(f32::INFINITY); - let mut new_max = Vec3::splat(f32::NEG_INFINITY); + let mut new_min = DVec3::splat(f64::INFINITY); + let mut new_max = DVec3::splat(f64::NEG_INFINITY); for corner in corners { let transformed = transform.apply(acadrust::types::Vector3::new( - corner.x as f64, - corner.y as f64, - corner.z as f64, + corner.x, + corner.y, + corner.z, )); - let transformed = - Vec3::new(transformed.x as f32, transformed.y as f32, transformed.z as f32); + let transformed = DVec3::new(transformed.x, transformed.y, transformed.z); new_min = new_min.min(transformed); new_max = new_max.max(transformed); } @@ -578,16 +577,16 @@ impl Camera { /// current target: `x`/`y` span the screen plane, `z` runs along the eye /// direction. The offset is taken in f64 before the cast, so a corner at /// UTM scale doesn't lose the difference to cancellation. - fn bounds_in_view(&self, min: Vec3, max: Vec3) -> [Vec3; 8] { + fn bounds_in_view(&self, min: DVec3, max: DVec3) -> [Vec3; 8] { let inv = self.rotation.inverse(); let mut out = [Vec3::ZERO; 8]; for (i, slot) in out.iter_mut().enumerate() { - let corner = Vec3::new( + let corner = DVec3::new( if i & 1 == 0 { min.x } else { max.x }, if i & 2 == 0 { min.y } else { max.y }, if i & 4 == 0 { min.z } else { max.z }, ); - *slot = inv * (corner.as_dvec3() - self.target).as_vec3(); + *slot = inv * (corner - self.target).as_vec3(); } out } @@ -595,7 +594,7 @@ impl Camera { /// Half-extent of `min..max` along the current eye direction (with the /// same 5% margin `ortho_depth_range` needs), measured from the target. /// This is exactly what `distance ± r` has to contain to avoid clipping. - fn depth_extent_in_view(&self, min: Vec3, max: Vec3) -> f32 { + fn depth_extent_in_view(&self, min: DVec3, max: DVec3) -> f32 { let depth_r = self .bounds_in_view(min, max) .iter() @@ -613,7 +612,11 @@ impl Camera { /// 800 km below its plane zoomed out to 800 km and became a dot. The two /// agree on a flat drawing, which is why it went unnoticed. pub fn fit_to_bounds(&mut self, min: Vec3, max: Vec3, aspect: f32) { - self.target = ((min + max) * 0.5).as_dvec3(); + self.fit_to_bounds_f64(min.as_dvec3(), max.as_dvec3(), aspect); + } + + pub fn fit_to_bounds_f64(&mut self, min: DVec3, max: DVec3, aspect: f32) { + self.target = (min + max) * 0.5; let corners = self.bounds_in_view(min, max); // Half-height the view needs so the box fits BOTH axes at the given // viewport aspect. The old circumscribed-radius rule fitted the box's @@ -628,7 +631,7 @@ impl Camera { // that to get the distance which just contains `half_h`, plus a small // border margin. self.distance = (half_h / (self.fov_y * 0.5).tan() * 1.1).max(1e-3); - self.fit_depth_to_bounds(min, max); + self.fit_depth_to_bounds_f64(min, max); } /// Size only the near/far span to `min..max`, leaving the pose alone. @@ -636,7 +639,7 @@ impl Camera { /// Split out of [`fit_to_bounds`] for the camera restored from a file's /// saved view: that pose must not move, but its depth range still has to /// cover the model or geometry outside it is silently clipped away. - pub fn fit_depth_to_bounds(&mut self, min: Vec3, max: Vec3) { + pub fn fit_depth_to_bounds_f64(&mut self, min: DVec3, max: DVec3) { // Cache the box so `ortho_depth_range` re-derives the near/far depth for // the live eye direction every frame — orbiting off the fitted pose then // never clips the drawing (#473). Keeping it tied to the model (not to @@ -648,7 +651,7 @@ impl Camera { self.depth_half_range = self.depth_extent_in_view(min, max); } - pub(crate) fn fitted_model_bounds(&self) -> Option<(Vec3, Vec3)> { + pub(crate) fn fitted_model_bounds(&self) -> Option<(DVec3, DVec3)> { self.model_bounds } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index cb96d6ea..29a9f25c 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3174,6 +3174,7 @@ impl Scene { model_render_mode: acadrust::entities::ViewportRenderMode, _hover_region: Option, show_viewcube: bool, + show_interaction: bool, viewcube_text_color: [f32; 4], ) -> Primitive { let nav_build_started = iced::time::Instant::now(); @@ -3181,7 +3182,7 @@ impl Scene { // Hover comes from the scene cell driven by the app-level // `CursorMoved` handler — the cube overlay sits above the shader // and would otherwise mask the move event from `Program::update`. - let hover_region = self.viewcube_hover.get(); + let hover_region = show_interaction.then(|| self.viewcube_hover.get()).flatten(); self.selection.borrow_mut().vp_size = (bounds.width, bounds.height); if bounds.height > 0.0 { self.set_render_aspect(bounds.width / bounds.height); @@ -3197,7 +3198,14 @@ impl Scene { .iter() .filter_map(|inst| { let force = self.refresh_consume(self.instance_id_for(inst)); - self.viewport_data_for(inst, canvas, hover_region, show_viewcube, force) + self.viewport_data_for( + inst, + canvas, + hover_region, + show_viewcube, + show_interaction, + force, + ) }) .collect(); // Empty viewports → blit nothing; the container background (model bg @@ -3226,9 +3234,10 @@ impl Scene { tile_idx: usize, model_render_mode: acadrust::entities::ViewportRenderMode, show_viewcube: bool, + show_interaction: bool, viewcube_text_color: [f32; 4], ) -> Primitive { - let hover_region = self.viewcube_hover.get(); + let hover_region = show_interaction.then(|| self.viewcube_hover.get()).flatten(); let canvas = (bounds.width.max(1.0), bounds.height.max(1.0)); let bg_color = [0.0, 0.0, 0.0, 0.0]; let tiles = self.model_tiles.borrow(); @@ -3279,7 +3288,14 @@ impl Scene { }; let force = self.refresh_consume(self.instance_id_for(&inst)); let viewports = self - .viewport_data_for(&inst, canvas, hover_region, show_viewcube, force) + .viewport_data_for( + &inst, + canvas, + hover_region, + show_viewcube, + show_interaction, + force, + ) .into_iter() .collect(); let perf_nav = perf_nav.map(|mut sample| { @@ -3304,6 +3320,7 @@ impl Scene { canvas: (f32, f32), hover_region: Option, show_viewcube: bool, + show_interaction: bool, force_rasterize: bool, ) -> Option { let display = self.viewport_display_settings(inst); @@ -3443,7 +3460,7 @@ impl Scene { // paper layout, model-space overlays go to all content viewports while // paper-space overlays stay on the sheet. This also keeps model-space // coordinates out of the full-canvas sheet pass (#540). - let show_live_overlay = if self.current_layout == "Model" { + let show_live_overlay = show_interaction && if self.current_layout == "Model" { true } else if self.active_viewport.is_some() { !inst.paper_sheet @@ -3592,11 +3609,12 @@ impl Scene { // paper area. Content viewport model builders are block-filtered too; // the scissor only clips their already-correct Model Space set. let (hatches, wipeout_hatches, paper_images) = if inst.paper_sheet { - let (hatches, wipeouts, images) = self.paper_sheet_render_models(); + let (hatches, wipeouts, images) = + self.paper_sheet_render_models_for_view(show_interaction); (hatches, wipeouts, Some(images)) } else { ( - self.hatch_models_for_viewport(inst.handle, &vp_frozen), + self.hatch_models_for_viewport(inst.handle, &vp_frozen, show_interaction), self.wipeout_models_for_viewport(inst.handle, &vp_frozen), None, ) @@ -3750,10 +3768,21 @@ impl Scene { camera_generation: self.camera_generation, wire_content_id, wire_patch, - selected_handles: Arc::new(self.selected.iter().copied().collect()), - hover_handles: Arc::new(self.hover_highlight_handles()), - selection_generation: self.selection_generation, - selected_sig: self.selected_set_sig(), + selected_handles: Arc::new(if show_interaction { + self.selected.iter().copied().collect() + } else { + rustc_hash::FxHashSet::default() + }), + hover_handles: Arc::new(if show_interaction { + self.hover_highlight_handles() + } else { + rustc_hash::FxHashSet::default() + }), + selection_generation: self + .selection_generation + .wrapping_mul(2) + .wrapping_add(u64::from(!show_interaction)), + selected_sig: if show_interaction { self.selected_set_sig() } else { 0 }, screen_rect, }) } diff --git a/src/scene/view/viewport_pane.rs b/src/scene/view/viewport_pane.rs index a87a8959..55aa42e8 100644 --- a/src/scene/view/viewport_pane.rs +++ b/src/scene/view/viewport_pane.rs @@ -16,6 +16,7 @@ use iced::{mouse, Event, Rectangle}; pub struct ViewportPane<'a> { pub scene: &'a Scene, pub show_viewcube: bool, + pub show_interaction: bool, /// Render mode applied to the active Model tile or active paper viewport. /// The app may supply a temporary gallery-hover mode; inactive viewports /// keep the values stored on their own tile/entity. @@ -33,12 +34,14 @@ impl<'a> ViewportPane<'a> { pub fn model( scene: &'a Scene, show_viewcube: bool, + show_interaction: bool, render_mode: acadrust::entities::ViewportRenderMode, viewcube_text_color: [f32; 4], ) -> Self { Self { scene, show_viewcube, + show_interaction, render_mode, pane: None, viewcube_text_color, @@ -50,6 +53,7 @@ impl<'a> ViewportPane<'a> { pub fn for_pane( scene: &'a Scene, show_viewcube: bool, + show_interaction: bool, render_mode: acadrust::entities::ViewportRenderMode, tile_idx: usize, viewcube_text_color: [f32; 4], @@ -57,6 +61,7 @@ impl<'a> ViewportPane<'a> { Self { scene, show_viewcube, + show_interaction, render_mode, pane: Some(tile_idx), viewcube_text_color, @@ -82,6 +87,7 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> idx, self.render_mode, self.show_viewcube, + self.show_interaction, self.viewcube_text_color, ), None => self.scene.build_viewports( @@ -89,6 +95,7 @@ impl<'a, Msg: std::fmt::Debug + Clone> shader::Program for ViewportPane<'a> self.render_mode, state.hover_region, self.show_viewcube, + self.show_interaction, self.viewcube_text_color, ), } From 44a40eda0b8e713101ece3ac29dfe897c87acb79 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 19:35:48 +0300 Subject: [PATCH 09/98] fix(model): restore saved solid booleans Reload exact B-reps from solid history or ACIS before model operations and pin the closed-cut kernel fix. --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/app/model_ops.rs | 100 +++++++++--------------------- src/modules/model/boolean_cmd.rs | 2 +- src/scene/convert/solid3d_tess.rs | 28 +++++++++ src/scene/entity.rs | 27 ++++++-- src/scene/mod.rs | 6 +- 7 files changed, 82 insertions(+), 85 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fb8d159e..32a1413b 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=895f8b7#895f8b7b4d53845608689ead46c1a122e352b79b" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=467c3fd#467c3fd02f419ab5af4030691699b34b25d2f51a" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index ddb09ba1..ec1734bb 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 = "db73dab", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "895f8b7", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "467c3fd", 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/model_ops.rs b/src/app/model_ops.rs index 2c67604a..a279e5e7 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,5 +1,5 @@ // 3D solid modelling support on the App: committing Model-tab primitives, -// and the Design-group boolean operations over the scene's session-cached +// and the Design-group boolean operations over the scene's cached // B-reps. // // The bodies come from the geometry kernel, so every operation here is on @@ -36,18 +36,30 @@ impl super::OpenCADStudio { handle } - /// Run a boolean (`union` / `subtract` / `intersect`) on exactly two - /// selected solids whose B-reps are in the session cache. - pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + fn selected_solid_handles(&mut self) -> Vec { let i = self.active_tab; - // Selected entities that have a cached B-rep. - let handles: Vec = self.tabs[i] + let mut handles: Vec = self.tabs[i] .scene .selected_handles_in_order() .into_iter() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(EntityType::Solid3D(_)) + ) + }) .collect(); + self.tabs[i].scene.restore_solid_models(&handles); + handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + handles + } + + /// Run a boolean (`union` / `subtract` / `intersect`) on exactly two + /// selected solids whose B-reps can be restored by the kernel. + pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + let i = self.active_tab; + let handles = self.selected_solid_handles(); if handles.len() != 2 { self.command_line .push_error(crate::t!("Boolean: select exactly two solids created this session.").as_ref()); @@ -91,14 +103,7 @@ impl super::OpenCADStudio { /// the same boolean path the Design-group tools use. pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("SLICE: select exactly one solid created this session.").as_ref()); @@ -167,14 +172,7 @@ impl super::OpenCADStudio { /// leaving the originals in place (a non-destructive boolean intersect). pub(super) fn solid_interfere(&mut self) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 2 { self.command_line .push_error(crate::t!("INTERFERE: select exactly two solids created this session.").as_ref()); @@ -207,14 +205,7 @@ impl super::OpenCADStudio { /// orientation, so it reuses the cached B-rep directly. pub(super) fn solid_rotate3d(&mut self, axis: usize, angle_deg: f64) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("3DROTATE: select exactly one solid created this session.").as_ref()); @@ -333,14 +324,7 @@ impl super::OpenCADStudio { /// loop on the way through; without that the copy lights black. pub(super) fn solid_mirror3d(&mut self, axis: usize) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("3DMIRROR: select exactly one solid created this session.").as_ref()); @@ -385,14 +369,7 @@ impl super::OpenCADStudio { dst: [[f64; 3]; 3], ) -> Task { let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("3DALIGN: select exactly one solid created this session.").as_ref()); @@ -453,14 +430,7 @@ impl super::OpenCADStudio { use acadrust::Line; let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.len() != 1 { self.command_line .push_error(crate::t!("SECTION: select exactly one solid created this session.").as_ref()); @@ -636,14 +606,7 @@ impl super::OpenCADStudio { use acadrust::types::Vector3; use acadrust::Line; let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.is_empty() { self.command_line .push_error(crate::t!("FLATSHOT: select a solid created this session.").as_ref()); @@ -677,14 +640,7 @@ impl super::OpenCADStudio { use acadrust::entities::{Surface, SurfaceKind, Wire as AWire}; use acadrust::types::Vector3; let i = self.active_tab; - let handles: Vec = self.tabs[i] - .scene - .selected - .iter() - .copied() - .filter(|h| !self.tabs[i].scene.is_layer_locked(*h)) - .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) - .collect(); + let handles = self.selected_solid_handles(); if handles.is_empty() { self.command_line .push_error(crate::t!("CONVTOSURFACE: select a solid created this session.").as_ref()); diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs index 94d926f1..44a29a08 100644 --- a/src/modules/model/boolean_cmd.rs +++ b/src/modules/model/boolean_cmd.rs @@ -1,6 +1,6 @@ // Boolean operations on 3D solids (Design group). The actual CSG runs in // `App::solid_boolean` (src/app/model_ops.rs) using the kernel on the -// session-cached B-reps; this module just names the operations. +// cached B-reps; this module just names the operations. /// Which boolean a Design-group tool performs on the two selected solids. #[derive(Clone, Copy, PartialEq, Eq, Debug)] diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 57792edd..76fdad52 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -1,5 +1,6 @@ use acadrust::entities::acis::{SabReader, SatBody, SatDocument}; use acadrust::entities::{Body, Region, Solid3D}; +use cadkernel::brep::{self, Body as KernelBody}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -154,6 +155,33 @@ fn parse_acis( }) } +pub fn kernel_body(solid: &Solid3D) -> Option { + let sat = parse_acis( + || solid.parse_sat(), + solid.acis_data.is_binary, + &solid.acis_data.sab_data, + )?; + let (mut bodies, loss) = cadkernel::acis::lift(&sat); + if !loss.is_empty() || bodies.len() != 1 { + return None; + } + let body = bodies.pop()?; + let source = body.provenance.source()?; + let Some((matrix, translation, scale)) = body_transform(&sat, source.index() as usize).ok()? + else { + return Some(body); + }; + brep::transform( + &body, + &brep::Placement { + x_axis: [scale * matrix[0], scale * matrix[1], scale * matrix[2]], + y_axis: [scale * matrix[3], scale * matrix[4], scale * matrix[5]], + z_axis: [scale * matrix[6], scale * matrix[7], scale * matrix[8]], + origin: translation, + }, + ) +} + fn remap_acis_material_bindings( set: &mut MeshLodSet, acis: &acadrust::entities::AcisData, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 9c638f1a..fb5e60f6 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -586,11 +586,28 @@ impl Scene { self.images.insert(handle, model); } self.refresh_meshes_for_handles(&[handle]); - if let Some(operation) = self.document.solid_history_operation(handle).cloned() { - if let Ok(body) = cadkernel::acis::rebuild_body(&operation) { - self.solid_models.insert(handle, body); - } - } + self.restore_solid_models(&[handle]); + } + + pub fn restore_solid_models(&mut self, handles: &[Handle]) { + let bodies: Vec<(Handle, cadkernel::brep::Body)> = handles + .iter() + .filter(|handle| !self.solid_models.contains_key(handle)) + .filter_map(|&handle| { + let from_history = self + .document + .solid_history_operation(handle) + .and_then(|operation| cadkernel::acis::rebuild_body(operation).ok()); + let body = from_history.or_else(|| match self.document.get_entity(handle) { + Some(EntityType::Solid3D(solid)) => { + crate::scene::convert::solid3d_tess::kernel_body(solid) + } + _ => None, + })?; + Some((handle, body)) + }) + .collect(); + self.solid_models.extend(bodies); } /// Re-tessellate only the named ACIS entities. The former edit path diff --git a/src/scene/mod.rs b/src/scene/mod.rs index e141c814..7f63fbe9 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1645,11 +1645,7 @@ pub struct Scene { /// owning block emits a transformed instance so a block placed at an /// INSERT scale renders at the right size. (#123) pub block_meshes: HashMap, - /// Live B-reps for solids created this session by the Model tab, keyed by - /// entity handle. Backs the Design-group boolean tools (a solid must be - /// here to be combined) and the exact-geometry save path, which writes - /// each one back out as ACIS rather than as facets. Not persisted — - /// rebuilt only by creating or combining primitives in-session. + /// Kernel B-reps used by solid operations and exact-geometry saves. pub solid_models: HashMap, /// GPU render data for raster images (RasterImage entities), keyed by handle. pub images: HashMap, From 236b7756f83605dac18599069c7b265a472f715c Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 23 Aug 2026 17:12:24 +0100 Subject: [PATCH 10/98] feat(draworder): HATCHTOBACK command and interactive DRAWORDER workflows Implement the AutoCAD compatible HATCHTOBACK (HB) command and enhance the interactive DRAWORDER command pipeline with comprehensive verb selection and viewport entity picking. Key implementations and architectural details: 1. HATCHTOBACK command execution * Isolates entities belonging to the active layout block record or global space. * Filters out entities residing on locked layers using an efficient hash set lookup. * Records targeted delta undo operations on SortEntitiesTable objects (~100 bytes) rather than snapshotting full document structures. * Performs granular draw depth cache invalidation via Scene::invalidate_draw_depth, preserving existing geometry tessellations and spatial indices. * Emits localized user notifications when no qualifying hatch entities are found. 2. Interactive DrawOrderCommand state machine * Supports preselected entities as well as interactive on demand selection gathering. * Implements subverb selection supporting single character shortcuts (A for Above, U for Under, F for Front, B for Back) and case insensitive keyword parsing. * Enables interactive reference object picking directly in the viewport via needs_entity_pick and on_entity_pick callbacks. * Supports hexadecimal handle specification directly from the command line interface. 3. Ecosystem and system integration * Bumps DEFAULT_ALIASES_VERSION from 2 to 3 to register the HB alias while preserving existing user overrides. * Integrates HATCHTOBACK and HB into dynamic autocomplete registries. * Adds localized message strings across all 20 Fluent translation catalogs. * Corrects hatch boundary serialization flags to ensure outermost boundary rings consistently carry the external attribute. --- assets/ocad.pgp | 1 + locales/ar-SA/opencadstudio.ftl | 1 + locales/bg-BG/opencadstudio.ftl | 1 + locales/cs-CZ/opencadstudio.ftl | 1 + locales/de-DE/opencadstudio.ftl | 1 + locales/en-US/opencadstudio.ftl | 1 + locales/es-ES/opencadstudio.ftl | 1 + locales/fi-FI/opencadstudio.ftl | 1 + locales/fr-FR/opencadstudio.ftl | 1 + locales/hi-IN/opencadstudio.ftl | 1 + locales/hu-HU/opencadstudio.ftl | 1 + locales/it-IT/opencadstudio.ftl | 1 + locales/ja-JP/opencadstudio.ftl | 1 + locales/ko-KR/opencadstudio.ftl | 1 + locales/nl-NL/opencadstudio.ftl | 1 + locales/pl-PL/opencadstudio.ftl | 1 + locales/pt-BR/opencadstudio.ftl | 1 + locales/ru-RU/opencadstudio.ftl | 1 + locales/tr-TR/opencadstudio.ftl | 1 + locales/zh-CN/opencadstudio.ftl | 1 + locales/zh-TW/opencadstudio.ftl | 1 + src/app/alias.rs | 26 +- src/app/commands/mod.rs | 6 +- src/app/commands/view.rs | 902 ++++++++++++++++++++++++++++++-- src/app/update/mod.rs | 2 +- src/command.rs | 59 ++- src/locale_catalog.rs | 1 + src/scene/entity.rs | 2 - src/scene/mod.rs | 5 + tests/block_hatch_export.rs | 18 +- 30 files changed, 980 insertions(+), 62 deletions(-) diff --git a/assets/ocad.pgp b/assets/ocad.pgp index 7c1598cd..84951d13 100644 --- a/assets/ocad.pgp +++ b/assets/ocad.pgp @@ -21,6 +21,7 @@ SPL, *SPLINE H, *HATCH BH, *HATCH HE, *HATCHEDIT +HB, *HATCHTOBACK BO, *BOUNDARY GD, *GRADIENT XL, *XLINE diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 54252271..62a03272 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -2347,6 +2347,7 @@ view = .zoom-extents = تكبير إلى النطاقات + .cmd-no-hatch-objects = __ocs_fmt_0__: ┘äϺ Ϭ┘êϼϻ ┘âϺϪ┘åϺϬ Ϭϩ┘ä┘è┘ä. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: لا توجد كائنات نص أو أبعاد. .cmd-not-yet-implemented = __ocs_fmt_0__: لم يُنفّذ بعد. .viewport-s-in-layout = __ocs_fmt_0__ منفذ عرض في التخطيط "__ocs_fmt_1__": diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 5da17064..fe6994b8 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Мащаб Обхват + .cmd-no-hatch-objects = __ocs_fmt_0__: ð¢ÐÅð╝ð░ ÐëÐÇð©Ðàð¥ð▓ÐèÐçð¢ð© ð¥ð▒ðÁð║Ðéð©. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: няма текстови обекти или оразмерявания. .cmd-not-yet-implemented = __ocs_fmt_0__: все още не е реализирано. .viewport-s-in-layout = __ocs_fmt_0__ изглед(а) в оформление "__ocs_fmt_1__": diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 7c2337f8..92c95855 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Přiblížení Rozsahy + .cmd-no-hatch-objects = __ocs_fmt_0__: ┼¥├ídn├® ┼írafovac├¡ objekty. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: žádný text ani kótovací objekty. .cmd-not-yet-implemented = __ocs_fmt_0__: dosud neimplementováno. .viewport-s-in-layout =Výřezy __ocs_fmt_0__ v rozložení "__ocs_fmt_1__": diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index c17b0730..87f3b152 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -2333,6 +2333,7 @@ view = .zoom-extents = Zoom Umfang + .cmd-no-hatch-objects = __ocs_fmt_0__: keine Schraffurobjekte. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: keine Text- oder Dimensionsobjekte. .cmd-not-yet-implemented = __ocs_fmt_0__: noch nicht umgesetzt. .viewport-s-in-layout = __ocs_fmt_0__ Ansichtsfenster(s) im Layout "__ocs_fmt_1__": diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 671366f4..6386d0d6 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2363,6 +2363,7 @@ view = .zoom-extents = Zoom Extents + .cmd-no-hatch-objects = __ocs_fmt_0__: no hatch objects. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: no text or dimension objects. .cmd-not-yet-implemented = __ocs_fmt_0__: not yet implemented. .viewport-s-in-layout = __ocs_fmt_0__ viewport(s) in layout "__ocs_fmt_1__": diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index cf6101bd..3f4a04fe 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -2335,6 +2335,7 @@ view = .zoom-extents = Zoom Extensión + .cmd-no-hatch-objects = __ocs_fmt_0__: no hay objetos de sombreado. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: no hay objetos de texto ni de cota. .cmd-not-yet-implemented = __ocs_fmt_0__: aún no implementado. .viewport-s-in-layout = __ocs_fmt_0__ ventanas gráficas en la presentación «__ocs_fmt_1__»: diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 01752032..08717ec7 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Zoomaa Laajuudet + .cmd-no-hatch-objects = __ocs_fmt_0__: ei t├ñytt├Âobjekteja. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: ei tekstiä tai mittaobjekteja. .cmd-not-yet-implemented = __ocs_fmt_0__: ei vielä otettu käyttöön. .viewport-s-in-layout =__ocs_fmt_0__-näkymä(t) asettelussa "__ocs_fmt_1__": diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 8270c93d..d2dbe5e9 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -2335,6 +2335,7 @@ view = .zoom-extents = Zoom Portée + .cmd-no-hatch-objects = __ocs_fmt_0__: aucun objet de hachure. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: aucun objet texte ou dimensionnel. .cmd-not-yet-implemented = __ocs_fmt_0__: pas encore mis en œuvre. .viewport-s-in-layout = __ocs_fmt_0__ fenêtre(s) dans la présentation « __ocs_fmt_1__ » : diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index abf200ab..d8f40a6c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -2323,6 +2323,7 @@ view = .zoom-extents = ज़ूम बहार + .cmd-no-hatch-objects = __ocs_fmt_0__: ÓñòÓÑïÓñê Óñ╣ÓÑêÓñÜ ÓñæÓñ¼ÓÑìÓñ£ÓÑçÓñòÓÑìÓñƒ Óñ¿Óñ╣ÓÑÇÓñéÓÑñ .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: कोई टेक्स्ट या डाइमेंशन ऑब्जेक्ट नहीं। .cmd-not-yet-implemented = __ocs_fmt_0__: अभी तक लागू नहीं है। .viewport-s-in-layout = लेआउट "__ocs_fmt_1__" में __ocs_fmt_0__ व्यूपोर्ट: diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 13e4c28c..0b7a8f7b 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Zoom Terjedelem + .cmd-no-hatch-objects = __ocs_fmt_0__: nincs sraffoz├ísi objektum. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nincs szöveg vagy méretobjektum. .cmd-not-yet-implemented = __ocs_fmt_0__: még nincs implementálva. .viewport-s-in-layout =__ocs_fmt_0__ nézetablak(ok) a „__ocs_fmt_1__” elrendezésben: diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 5acada86..8c915bb8 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Zoom Estensioni + .cmd-no-hatch-objects = __ocs_fmt_0__: nessun oggetto tratteggio. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nessun testo o oggetto dimensione. .cmd-not-yet-implemented = __ocs_fmt_0__: non ancora implementato. .viewport-s-in-layout =Finestre __ocs_fmt_0__ nel layout "__ocs_fmt_1__": diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index c35743c2..4abf157c 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -2323,6 +2323,7 @@ view = .zoom-extents = 全体表示 ズーム + .cmd-no-hatch-objects = __ocs_fmt_0__: ÒâÅÒââÒâüÒâ│Òé░ Òé¬ÒâûÒé©ÒéºÒé»ÒâêÒüîÒüéÒéèÒü¥ÒüøÒéôÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: テキストまたは寸法オブジェクトがありません。 .cmd-not-yet-implemented = __ocs_fmt_0__: まだ実装されていません。 .viewport-s-in-layout = レイアウト「__ocs_fmt_1__」内の __ocs_fmt_0__ 個のビューポート: diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index d2c649d8..9c1b5738 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = 줌 범위 + .cmd-no-hatch-objects = __ocs_fmt_0__: Ýò┤ý╣ÿ Û░Øý▓┤Û░Ç ýùåýèÁÙïêÙïñ. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: 문자 또는 치수 객체가 없습니다. .cmd-not-yet-implemented = __ocs_fmt_0__: 아직 구현되지 않았습니다. .viewport-s-in-layout ="__ocs_fmt_1__" 레이아웃의 __ocs_fmt_0__ 뷰포트: diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 246e073f..876a6cbf 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -2335,6 +2335,7 @@ view = .zoom-extents = Zoomen Maten + .cmd-no-hatch-objects = __ocs_fmt_0__: geen arceerobjecten. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: geen tekst- of maatobjecten. .cmd-not-yet-implemented = __ocs_fmt_0__: nog niet geïmplementeerd. .viewport-s-in-layout = __ocs_fmt_0__ weergavevenster(s) in indeling "__ocs_fmt_1__": diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index cf5a981e..9ac73584 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -2334,6 +2334,7 @@ view = .zoom-extents = Powiększ Zakresy + .cmd-no-hatch-objects = __ocs_fmt_0__: brak obiekt├│w kreskowania. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: brak obiektów tekstowych i wymiarowych. .cmd-not-yet-implemented = __ocs_fmt_0__: jeszcze nie wdrożony. .viewport-s-in-layout =Okna widokowe __ocs_fmt_0__ w układzie „__ocs_fmt_1__”: diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 128a43dc..4b70122d 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -2335,6 +2335,7 @@ view = .zoom-extents = Zoom nas extensões + .cmd-no-hatch-objects = __ocs_fmt_0__: nenhum objeto de hachura. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: nenhum objeto de texto ou cota. .cmd-not-yet-implemented = __ocs_fmt_0__: ainda não implementado. .viewport-s-in-layout = __ocs_fmt_0__ janelas de visualização no layout “__ocs_fmt_1__”: diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 0f154f49..957709db 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -2338,6 +2338,7 @@ view = .zoom-extents = Зум размеры + .cmd-no-hatch-objects = __ocs_fmt_0__: ð¢ðÁÐé ð¥ð▒ÐèðÁð║Ðéð¥ð▓ ÐêÐéÐÇð©Ðàð¥ð▓ð║ð©. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: нет текстовых или размерных объектов. .cmd-not-yet-implemented = __ocs_fmt_0__: еще не реализовано. .viewport-s-in-layout = Видовые экраны (__ocs_fmt_0__) на листе «__ocs_fmt_1__»: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 5c3f9ff4..4f2d10e7 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -2303,6 +2303,7 @@ view = .zoom-window-specify-first-corner = ZOOM WINDOW İlk köşeyi belirtin: .zoom-window-specify-opposite-corner = ZOOM WINDOW Karşı köşeyi belirtin: .zoom-extents = Sınırlara Yakınlaştır + .cmd-no-hatch-objects = __ocs_fmt_0__: Tarama nesnesi yok. .cmd-no-text-or-dimension-objects = __ocs_fmt_0__: Metin veya ölçü nesnesi yok. .cmd-not-yet-implemented = __ocs_fmt_0__: Henüz uygulanmadı. .viewport-s-in-layout = "__ocs_fmt_1__" yerleşiminde __ocs_fmt_0__ görünüm alanı: diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 3440c473..0fb98a5a 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -2318,6 +2318,7 @@ view = .zoom-extents = 缩放 范围 + .cmd-no-hatch-objects = __ocs_fmt_0__:µ▓íµ£ëÕø¥µíêÕí½ÕààÕ»╣Þ▒íÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__:没有文本或维度对象。 .cmd-not-yet-implemented = __ocs_fmt_0__:尚未执行。 .viewport-s-in-layout = 布局“__ocs_fmt_1__”中的视口(__ocs_fmt_0__ 个): diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index fb781a28..21f275b3 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -2330,6 +2330,7 @@ view = .zoom-extents = 縮放 範圍 + .cmd-no-hatch-objects = __ocs_fmt_0__:µ▓Ƶ£ëÕí½ÕààþÀÜþë®õ╗ÂÒÇé .cmd-no-text-or-dimension-objects = __ocs_fmt_0__:沒有文字或維度物件。 .cmd-not-yet-implemented = __ocs_fmt_0__:尚未實作。 .viewport-s-in-layout = 配置“__ocs_fmt_1__”中的視口(__ocs_fmt_0__ 個): diff --git a/src/app/alias.rs b/src/app/alias.rs index fc305430..d9c3aeb0 100644 --- a/src/app/alias.rs +++ b/src/app/alias.rs @@ -73,8 +73,9 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases"; /// Version of the shipped default alias table. Bump this whenever the embedded /// defaults add aliases or change a default target, so existing profiles can be /// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped -/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases. -const DEFAULT_ALIASES_VERSION: u32 = 2; +/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3 +/// introduced HB (HATCHTOBACK). +const DEFAULT_ALIASES_VERSION: u32 = 3; #[cfg(target_arch = "wasm32")] const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version"; @@ -91,6 +92,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] { ("RE", "REGEN"), ("REA", "REGENALL"), ], + 3 => &[("HB", "HATCHTOBACK")], _ => &[], } } @@ -330,17 +332,18 @@ mod tests { .collect() } - /// An existing profile that predates the REDRAW aliases gains exactly the + /// An existing profile that predates the REDRAW and HB aliases gains exactly the /// new defaults, with every pre-existing mapping kept. #[test] fn migration_adds_new_defaults_preserving_existing() { let mut stored = map(&[("L", "LINE"), ("Z", "ZOOM"), ("HI", "HIDE")]); let changed = migrate_aliases(&mut stored, 0); - assert!(changed, "migrating from v0 to v2 must add defaults"); + assert!(changed, "migrating from v0 to v3 must add defaults"); assert_eq!(stored.get("R").map(String::as_str), Some("REDRAW")); assert_eq!(stored.get("RA").map(String::as_str), Some("REDRAWALL")); assert_eq!(stored.get("RE").map(String::as_str), Some("REGEN")); assert_eq!(stored.get("REA").map(String::as_str), Some("REGENALL")); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); // Existing entries must survive untouched. assert_eq!(stored.get("L").map(String::as_str), Some("LINE")); assert_eq!(stored.get("Z").map(String::as_str), Some("ZOOM")); @@ -375,10 +378,10 @@ mod tests { assert_eq!(stored.get("L").map(String::as_str), Some("LINE")); } - /// Migrating a mid-range profile (one that saw v1) only pulls in v2's + /// Migrating a mid-range profile (one that saw v1) pulls in v2's and v3's /// additions, not hypothetical earlier ones. #[test] - fn migration_from_version_one_adds_v2_aliases() { + fn migration_from_version_one_adds_v2_and_v3_aliases() { let mut stored = map(&[("L", "LINE")]); let changed = migrate_aliases(&mut stored, 1); assert!(changed); @@ -386,5 +389,16 @@ mod tests { assert_eq!(stored.get("RA").map(String::as_str), Some("REDRAWALL")); assert_eq!(stored.get("RE").map(String::as_str), Some("REGEN")); assert_eq!(stored.get("REA").map(String::as_str), Some("REGENALL")); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); + } + + /// Migrating a profile that saw v2 pulls in v3's HB addition only. + #[test] + fn migration_from_version_two_adds_v3_aliases() { + let mut stored = map(&[("L", "LINE")]); + let changed = migrate_aliases(&mut stored, 2); + assert!(changed); + assert_eq!(stored.get("R"), None); + assert_eq!(stored.get("HB").map(String::as_str), Some("HATCHTOBACK")); } } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index ed249296..63bc9f7b 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -18,7 +18,7 @@ mod view; // `DrawOrderRefCommand` lives in the `view` family file but is referenced by // path (`commands::DrawOrderRefCommand`) from `update.rs`, so re-export it at // the module root to keep that path valid. -pub(crate) use view::DrawOrderRefCommand; +pub(crate) use view::DrawOrderCommand; impl OpenCADStudio { /// First `"{prefix}{n}"` (n ≥ 1) not already used by a block record in the @@ -397,9 +397,11 @@ inventory::submit!(crate::command::CommandRegistration { "CUILOAD", // Save every open drawing. "SAVEALL", - // Draw-order: all text/dims to front or back. + // Draw-order: all text/dims to front or back; hatches to back. "TEXTTOFRONT", "TEXTTOBACK", + "HATCHTOBACK", + "HB", // Criteria-based selection (same as QSELECT) + copy with picked base. "FILTER", "FI", diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 89ceea80..0e1779ca 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -772,17 +772,20 @@ impl OpenCADStudio { // the front (or back) of the draw order via the DRAWORDER machinery. "TEXTTOFRONT" | "TEXTTOBACK" => { let to_front = cmd.ends_with("FRONT"); + let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); let handles: rustc_hash::FxHashSet = self.tabs[i] .scene .document .entities() .filter(|e| { - matches!( - e, - acadrust::EntityType::Text(_) - | acadrust::EntityType::MText(_) - | acadrust::EntityType::Dimension(_) - ) + let c = e.common(); + (c.owner_handle == block_handle || c.owner_handle.is_null()) + && matches!( + e, + acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + | acadrust::EntityType::Dimension(_) + ) }) .map(|e| e.common().handle) .collect(); @@ -802,15 +805,129 @@ impl OpenCADStudio { ); } - "DRAWORDER" => { - use crate::command::SelectThenKeywordCommand; - let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = SelectThenKeywordCommand::new( - "DRAWORDER", - "DRAWORDER [Front / Back] (Above/Under by typing):", - vec![("Front", "FRONT", None), ("Back", "BACK", None)], - has_sel, + // HATCHTOBACK ÔÇö move every hatch object in the active space to the back of the draw order. + "HATCHTOBACK" => { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let block_handle = self.tabs[i].scene.current_layout_block_handle_pub(); + let doc_ref = &self.tabs[i].scene.document; + + // 1. Single scan over objects to find existing SortEntitiesTable handle & overrides. + let mut existing_table_handle = None; + let mut overrides: Option> = None; + for (h, obj) in &doc_ref.objects { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + existing_table_handle = Some(*h); + if !t.is_empty() { + overrides = Some( + t.entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(), + ); + } + break; + } + } + } + + // 2. Pre-filter locked layers with zero string allocation. + let locked_layers: rustc_hash::FxHashSet<&str> = doc_ref + .layers + .iter() + .filter(|l| l.is_locked()) + .map(|l| l.name.as_str()) + .collect(); + let has_locked_layers = !locked_layers.is_empty(); + + // 3. Single pass over entities in the active space. + let mut hatches_to_move: Vec = Vec::new(); + let mut min_eff = u64::MAX; + + for e in doc_ref.entities() { + let c = e.common(); + if c.owner_handle != block_handle && !c.owner_handle.is_null() { + continue; + } + let hv = c.handle.value(); + if matches!(e, acadrust::EntityType::Hatch(_)) { + if !has_locked_layers || !locked_layers.contains(c.layer.as_str()) { + hatches_to_move.push(c.handle); + } + } else { + let eff = match &overrides { + Some(map) => map.get(&hv).copied().unwrap_or(hv), + None => hv, + }; + min_eff = min_eff.min(eff); + } + } + + if hatches_to_move.is_empty() { + self.command_line + .push_info(crate::tf!("{cmd}: no hatch objects.").as_ref()); + return Some(Task::none()); + } + + if min_eff == u64::MAX { + min_eff = 1; + } + + // 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone). + let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true); + + // 5. Update or insert SortEntitiesTable directly. + let table_before = existing_table_handle + .and_then(|h| self.tabs[i].scene.document.objects.get(&h).cloned()); + let th = existing_table_handle.unwrap_or_else(|| { + let nh = acadrust::Handle::new(self.tabs[i].scene.document.next_handle()); + let mut table = SortEntitiesTable::for_block(block_handle); + table.handle = nh; + self.tabs[i] + .scene + .document + .objects + .insert(nh, ObjectType::SortEntitiesTable(table)); + nh + }); + self.tabs[i].scene.record_undo_object_before(th, table_before); + + if let Some(ObjectType::SortEntitiesTable(table)) = + self.tabs[i].scene.document.objects.get_mut(&th) + { + for (k, h) in hatches_to_move.iter().enumerate() { + let sort = min_eff.saturating_sub(1 + k as u64).max(1); + table.add_entry(*h, acadrust::Handle::new(sort)); + } + } + + if let Some(pending) = pending_delta { + self.commit_undo_delta(i, pending); + } + + // 6. Invalidate ONLY draw-depth cache without dropping whole-drawing tessellations or spatial indexes. + let changes: Vec<(acadrust::Handle, crate::scene::ChangeKind)> = hatches_to_move + .iter() + .map(|h| (*h, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.invalidate_draw_depth(); + + self.tabs[i].dirty = true; + self.command_line.push_info( + crate::tf!("DRAWORDER: moved {} entities to back.", hatches_to_move.len()).as_ref(), ); + + return Some(Task::none()); + } + + "DRAWORDER" => { + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + let c = DrawOrderCommand::new(selected); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -1083,38 +1200,159 @@ impl OpenCADStudio { } } -// ── Draw Order: interactive reference-object pick ────────────────────────── - -/// Moves a captured selection above or below a reference object the user -/// picks in the viewport. On pick it relaunches `DRAWORDER A|U ` -/// with the captured handles reinstalled as the selection, so the existing -/// command path performs the actual reorder. -pub(crate) struct DrawOrderRefCommand { - to_move: Vec, - above: bool, +// ── Draw Order: interactive command ────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DrawOrderStep { + SelectObjects, + ChooseVerb, + PickReference { above: bool }, } -impl DrawOrderRefCommand { - pub(crate) fn new(to_move: Vec, above: bool) -> Self { - Self { to_move, above } +/// Interactive front-end for the DRAWORDER command. +/// +/// Flow: +/// 1. If nothing is selected, prompt to select objects (Enter confirms). +/// 2. Prompt for verb: `[Above / Under / Front / Back]`. +/// - `Front` / `F`: moves selection to front. +/// - `Back` / `B` / Enter: moves selection to back. +/// - `Above` / `A` / `Under` / `U`: advances to reference object pick. +/// 3. Reference object pick: user can click the reference entity in the viewport +/// or type its hex handle on the command line. +pub(crate) struct DrawOrderCommand { + selected: Vec, + step: DrawOrderStep, +} + +impl DrawOrderCommand { + pub(crate) fn new(selected: Vec) -> Self { + let step = if selected.is_empty() { + DrawOrderStep::SelectObjects + } else { + DrawOrderStep::ChooseVerb + }; + Self { selected, step } + } + + pub(crate) fn for_reference_pick(selected: Vec, above: bool) -> Self { + Self { + selected, + step: DrawOrderStep::PickReference { above }, + } } } -impl CadCommand for DrawOrderRefCommand { +impl CadCommand for DrawOrderCommand { fn name(&self) -> &'static str { "DRAWORDER" } fn prompt(&self) -> String { - if self.above { - crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() - } else { - crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() + match self.step { + DrawOrderStep::SelectObjects => { + crate::t!("DRAWORDER select objects, then press Enter:").into_owned() + } + DrawOrderStep::ChooseVerb => { + crate::t!("DRAWORDER [Above / Under / Front / Back] :").into_owned() + } + DrawOrderStep::PickReference { above: true } => { + crate::t!("DRAWORDER Select reference object (move selection above):").into_owned() + } + DrawOrderStep::PickReference { above: false } => { + crate::t!("DRAWORDER Select reference object (move selection under):").into_owned() + } + } + } + + fn options(&self) -> Vec { + match self.step { + DrawOrderStep::ChooseVerb => vec![ + crate::command::CmdOption::new("Above", "ABOVE"), + crate::command::CmdOption::new("Under", "UNDER"), + crate::command::CmdOption::new("Front", "FRONT"), + crate::command::CmdOption::new("Back", "BACK"), + ], + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + !matches!(self.step, DrawOrderStep::SelectObjects) + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, DrawOrderStep::SelectObjects) + } + + fn on_selection_complete(&mut self, handles: Vec) -> crate::command::CmdResult { + self.selected = handles; + crate::command::CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> crate::command::CmdResult { + match self.step { + DrawOrderStep::SelectObjects => { + if self.selected.is_empty() { + crate::command::CmdResult::Cancel + } else { + self.step = DrawOrderStep::ChooseVerb; + crate::command::CmdResult::NeedPoint + } + } + DrawOrderStep::ChooseVerb => { + // Bare Enter defaults to Back + let handles = std::mem::take(&mut self.selected); + crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles) + } + DrawOrderStep::PickReference { .. } => crate::command::CmdResult::Cancel, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let t = text.trim(); + if t.is_empty() { + return None; + } + match self.step { + DrawOrderStep::SelectObjects => None, + DrawOrderStep::ChooseVerb => { + let up = t.to_uppercase(); + match up.as_str() { + "F" | "FRONT" => { + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch("DRAWORDER FRONT".into(), handles)) + } + "B" | "BACK" => { + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch("DRAWORDER BACK".into(), handles)) + } + "A" | "ABOVE" => { + self.step = DrawOrderStep::PickReference { above: true }; + Some(crate::command::CmdResult::NeedPoint) + } + "U" | "UNDER" | "BELOW" => { + self.step = DrawOrderStep::PickReference { above: false }; + Some(crate::command::CmdResult::NeedPoint) + } + _ => Some(crate::command::CmdResult::NeedPoint), + } + } + DrawOrderStep::PickReference { above } => { + let hex_str = t.trim_start_matches("0x").trim_start_matches("0X"); + if let Ok(val) = u64::from_str_radix(hex_str, 16) { + let opt = if above { "A" } else { "U" }; + let cmd = format!("DRAWORDER {} {:x}", opt, val); + let handles = std::mem::take(&mut self.selected); + Some(crate::command::CmdResult::Relaunch(cmd, handles)) + } else { + Some(crate::command::CmdResult::NeedPoint) + } + } } } fn needs_entity_pick(&self) -> bool { - true + matches!(self.step, DrawOrderStep::PickReference { .. }) } fn on_entity_pick( @@ -1125,16 +1363,606 @@ impl CadCommand for DrawOrderRefCommand { if handle.is_null() { return crate::command::CmdResult::NeedPoint; } - let opt = if self.above { "A" } else { "U" }; - let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); - crate::command::CmdResult::Relaunch(cmd, std::mem::take(&mut self.to_move)) + if let DrawOrderStep::PickReference { above } = self.step { + let opt = if above { "A" } else { "U" }; + let cmd = format!("DRAWORDER {} {:x}", opt, handle.value()); + let handles = std::mem::take(&mut self.selected); + crate::command::CmdResult::Relaunch(cmd, handles) + } else { + crate::command::CmdResult::NeedPoint + } } fn on_point(&mut self, _pt: glam::DVec3) -> crate::command::CmdResult { crate::command::CmdResult::NeedPoint } +} - fn on_enter(&mut self) -> crate::command::CmdResult { - crate::command::CmdResult::Cancel +#[cfg(test)] +mod tests { + use super::*; + use crate::app::OpenCADStudio; + use acadrust::objects::ObjectType; + use acadrust::EntityType; + + fn fresh_app() -> OpenCADStudio { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app + } + + #[test] + fn hatchtoback_no_hatches_warns_and_noop() { + let mut app = fresh_app(); + let _ = app.run_command_line("HATCHTOBACK"); + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "No SortEntitiesTable created when no hatches exist"); + } + + #[test] + fn hatchtoback_moves_hatches_to_back() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for the active layout block"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!( + hatch_sort < line1_sort, + "Hatch sort handle ({hatch_sort}) must be behind line 1 ({line1_sort})" + ); + assert!( + hatch_sort < line2_sort, + "Hatch sort handle ({hatch_sort}) must be behind line 2 ({line2_sort})" + ); + } + + #[test] + fn hb_alias_moves_hatches_to_back() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HB"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for the active layout block"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + + assert!( + hatch_sort < line_sort, + "Hatch sort handle ({hatch_sort}) must be behind line ({line_sort}) via HB alias" + ); + } + + #[test] + fn hatchtoback_ignores_hatches_in_other_spaces() { + let mut app = fresh_app(); + let i = app.active_tab; + let other_block = acadrust::Handle::new(0x9999); + let h_foreign = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(h_foreign) { + entity.common_mut().owner_handle = other_block; + } + + // When no hatches exist in the current layout, HATCHTOBACK should no-op. + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "Foreign hatch must not trigger table creation in active layout"); + + // Now add a line and a hatch in the active layout. + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for current layout"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + assert!(!entries.contains_key(&h_foreign.value()), "Foreign hatch must not be in active layout table"); + assert!(entries.contains_key(&h_hatch.value()), "Active layout hatch must be in table"); + let hatch_sort = entries[&h_hatch.value()]; + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort < line_sort); + } + + #[test] + fn hatchtoback_multiple_calls_are_idempotent() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + let h1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let h2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + + assert!(h1_sort < line_sort); + assert!(h2_sort < line_sort); + } + + #[test] + fn hatchtoback_preserves_active_selection() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let _h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // User had the line selected before running HATCHTOBACK. + app.tabs[i].scene.replace_selection(std::iter::once(h_line).collect()); + + let _ = app.run_command_line("HATCHTOBACK"); + + let selected = app.tabs[i].scene.selected_handles_in_order(); + assert_eq!(selected, vec![h_line], "HATCHTOBACK must not overwrite existing user selection"); + } + + #[test] + fn hatchtoback_skips_hatches_on_locked_layers() { + let mut app = fresh_app(); + let i = app.active_tab; + + // Lock layer "LOCKED_LAYER". + app.tabs[i].scene.ensure_layer("LOCKED_LAYER"); + if let Some(layer) = app.tabs[i].scene.document.layers.get_mut("LOCKED_LAYER") { + layer.flags.locked = true; + } + + let mut locked_hatch = acadrust::entities::Hatch::default(); + locked_hatch.common.layer = "LOCKED_LAYER".into(); + let h_locked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(locked_hatch)); + + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + // Run HATCHTOBACK when only a locked hatch exists: it should not move the locked hatch. + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table_exists = app.tabs[i].scene.document.objects.values().any(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + t.block_owner_handle == block_handle + } else { + false + } + }); + assert!(!table_exists, "Locked hatch must not trigger table creation"); + + // Now add an unlocked hatch. + let h_unlocked_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist for unlocked hatch"); + + let entries: rustc_hash::FxHashMap = table + .entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(); + + assert!(!entries.contains_key(&h_locked_hatch.value()), "Locked hatch must not be reordered"); + assert!(entries.contains_key(&h_unlocked_hatch.value()), "Unlocked hatch must be reordered"); + let hatch_sort = entries[&h_unlocked_hatch.value()]; + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort < line_sort); + } + + #[test] + fn hatchtoback_undo_restores_draw_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let _h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let table = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }).expect("SortEntitiesTable should exist"); + assert!(table.entries().any(|e| e.entity_handle == h_hatch)); + + // Perform UNDO. + let _ = app.update(crate::app::Message::Undo); + + let table_after_undo = app.tabs[i].scene.document.objects.values().find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some(t); + } + } + None + }); + // Table was created fresh by HATCHTOBACK, so undo should remove it or leave it empty of the hatch. + let has_hatch = table_after_undo.map_or(false, |t| t.entries().any(|e| e.entity_handle == h_hatch)); + assert!(!has_hatch, "Undo must revert the SortEntitiesTable entry"); + } + + fn effective_sort_map( + app: &OpenCADStudio, + ) -> rustc_hash::FxHashMap { + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + app.tabs[i] + .scene + .document + .objects + .values() + .find_map(|obj| { + if let ObjectType::SortEntitiesTable(t) = obj { + if t.block_owner_handle == block_handle { + return Some( + t.entries() + .map(|e| (e.entity_handle.value(), e.sort_handle.value())) + .collect(), + ); + } + } + None + }) + .unwrap_or_default() + } + + // Regression guard for DRAWORDER BACK anchoring. Written while auditing + // the draw-order code, where BACK *looked* like it skipped the baseline + // scan and clamped every moved entity onto sort key 1 (a tie). Running + // this test disproved that — `to_front_opt` is Some(false) for BACK, so + // the baseline scan does run. Kept to lock in the strict-order behavior: + // a second BACK must land strictly below earlier assignments, never tie. + #[test] + fn draworder_back_twice_keeps_strict_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Send the hatch to back, then send line 1 behind it: the second BACK + // must anchor strictly below the first assignment, never tie with it. + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + app.tabs[i].scene.replace_selection(std::iter::once(h_line1).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + + assert!( + line1_sort < hatch_sort, + "second BACK must land strictly below the first ({line1_sort} vs {hatch_sort})" + ); + } + + #[test] + fn draworder_front_moves_hatch_in_front_of_all() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER FRONT"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!(hatch_sort > line1_sort, "hatch ({hatch_sort}) must render in front of line 1 ({line1_sort})"); + assert!(hatch_sort > line2_sort, "hatch ({hatch_sort}) must render in front of line 2 ({line2_sort})"); + } + + #[test] + fn draworder_back_moves_hatch_behind_all() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line1_sort = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let line2_sort = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert!(hatch_sort < line1_sort, "hatch ({hatch_sort}) must render behind line 1 ({line1_sort})"); + assert!(hatch_sort < line2_sort, "hatch ({hatch_sort}) must render behind line 2 ({line2_sort})"); + } + + #[test] + fn draworder_above_reference_puts_hatch_in_front_of_object() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let cmd = format!("DRAWORDER ABOVE {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + + assert!(hatch_sort > ref_sort, "hatch ({hatch_sort}) must be above reference object ({ref_sort})"); + } + + #[test] + fn draworder_under_reference_puts_hatch_behind_object() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i].scene.replace_selection(std::iter::once(h_hatch).collect()); + let cmd = format!("DRAWORDER UNDER {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + + assert!(hatch_sort < ref_sort, "hatch ({hatch_sort}) must be under reference object ({ref_sort})"); + } + + #[test] + fn draworder_interactive_shortcuts_f_b_a_u() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let _h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // 1. Shortcut 'F' -> Front + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + assert_eq!(cmd.wants_text_input(), true); + let res = cmd.on_text_input("F"); + match res { + Some(crate::command::CmdResult::Relaunch(c, handles)) => { + assert_eq!(c, "DRAWORDER FRONT"); + assert_eq!(handles, vec![h_hatch]); + } + _ => panic!("Expected Relaunch for shortcut F"), + } + + // 2. Shortcut 'B' -> Back + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let res = cmd.on_text_input("B"); + match res { + Some(crate::command::CmdResult::Relaunch(c, handles)) => { + assert_eq!(c, "DRAWORDER BACK"); + assert_eq!(handles, vec![h_hatch]); + } + _ => panic!("Expected Relaunch for shortcut B"), + } + + // 3. Shortcut 'A' -> Advances to PickReference (above) + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + assert!(!cmd.needs_entity_pick()); + let res = cmd.on_text_input("A"); + assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); + assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'A'"); + + // 4. Shortcut 'U' -> Advances to PickReference (under) + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let res = cmd.on_text_input("U"); + assert!(matches!(res, Some(crate::command::CmdResult::NeedPoint))); + assert!(cmd.needs_entity_pick(), "Needs entity pick after choosing 'U'"); + } + + #[test] + fn draworder_interactive_above_under_viewport_entity_pick() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Above with viewport pick + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("A"); + assert!(cmd.needs_entity_pick()); + let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); + match pick_res { + crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from entity pick"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort > ref_sort, "Hatch must be above reference after viewport pick"); + + // Under with viewport pick + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("U"); + assert!(cmd.needs_entity_pick()); + let pick_res = cmd.on_entity_pick(h_ref, glam::DVec3::ZERO); + match pick_res { + crate::command::CmdResult::Relaunch(relaunch_cmd, handles) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER U {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from entity pick"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort < ref_sort, "Hatch must be under reference after viewport pick"); + } + + #[test] + fn draworder_interactive_typed_hex_handle() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let mut cmd = DrawOrderCommand::new(vec![h_hatch]); + let _ = cmd.on_text_input("Above"); + let hex_input = format!("0x{:x}", h_ref.value()); + let typed_res = cmd.on_text_input(&hex_input); + match typed_res { + Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { + assert_eq!(relaunch_cmd, format!("DRAWORDER A {:x}", h_ref.value())); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch from typed hex handle"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + assert!(hatch_sort > ref_sort); + } + + #[test] + fn draworder_interactive_without_preselection() { + use crate::command::CadCommand; + + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Start command with no pre-selection + let mut cmd = DrawOrderCommand::new(vec![]); + assert!(cmd.is_selection_gathering()); + assert!(!cmd.wants_text_input()); + + // Gather selection + let _ = cmd.on_selection_complete(vec![h_hatch]); + let enter_res = cmd.on_enter(); + assert!(matches!(enter_res, crate::command::CmdResult::NeedPoint)); + assert!(!cmd.is_selection_gathering()); + assert!(cmd.wants_text_input()); + + // Choose verb Front + let res = cmd.on_text_input("F"); + match res { + Some(crate::command::CmdResult::Relaunch(relaunch_cmd, handles)) => { + assert_eq!(relaunch_cmd, "DRAWORDER FRONT"); + assert_eq!(handles, vec![h_hatch]); + app.tabs[i].scene.replace_selection(handles.into_iter().collect()); + let _ = app.run_command_line(&relaunch_cmd); + } + _ => panic!("Expected Relaunch"), + } + + let entries = effective_sort_map(&app); + let hatch_sort = entries.get(&h_hatch.value()).copied().unwrap_or(h_hatch.value()); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + assert!(hatch_sort > line_sort); } } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 66ed2b60..679b3ad9 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4129,7 +4129,7 @@ impl OpenCADStudio { .push_error(crate::t!("DRAWORDER: select entities first.").as_ref()); } else { use crate::command::CadCommand; - let cmd = super::commands::DrawOrderRefCommand::new(to_move, above); + let cmd = super::commands::DrawOrderCommand::for_reference_pick(to_move, above); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/command.rs b/src/command.rs index fcc8344f..7cc6382e 100644 --- a/src/command.rs +++ b/src/command.rs @@ -542,6 +542,34 @@ impl KeywordCommand { } } +fn match_cmd_option<'a>( + options: &'a [(&'static str, &'static str, Option<&'static str>)], + text: &str, +) -> Option<&'a (&'static str, &'static str, Option<&'static str>)> { + let t = text.trim(); + let up = t.to_uppercase(); + if up.is_empty() { + return None; + } + // 1. Exact match on keyword or label (case-insensitive) + if let Some(opt) = options.iter().find(|(label, k, _)| { + k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) + }) { + return Some(opt); + } + // 2. Unambiguous prefix match on keyword or label (e.g. "A" -> "ABOVE", "L" -> "LEFT") + let matches: Vec<_> = options + .iter() + .filter(|(label, k, _)| { + k.to_uppercase().starts_with(&up) || label.to_uppercase().starts_with(&up) + }) + .collect(); + if matches.len() == 1 { + return Some(matches[0]); + } + None +} + impl CadCommand for KeywordCommand { fn name(&self) -> &'static str { self.name @@ -581,10 +609,7 @@ impl CadCommand for KeywordCommand { // Consumed inputs that keep prompting return `Some(NeedPoint)` — // `None` would hand the same text to the command a second time. None => { - let up = t.to_uppercase(); - let Some((_, keyword, value_prompt)) = self.options.iter().find(|(label, k, _)| { - k.eq_ignore_ascii_case(&up) || label.eq_ignore_ascii_case(t) - }) + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — keep prompting rather than dispatch garbage. return Some(CmdResult::NeedPoint); @@ -783,11 +808,18 @@ impl CadCommand for SelectThenKeywordCommand { return None; } match self.pending { - Some((keyword, _)) => Some(CmdResult::Dispatch(format!("{} {keyword} {t}", self.name))), + Some((keyword, _)) => { + if self.selected.is_empty() { + Some(CmdResult::Dispatch(format!("{} {keyword} {t}", self.name))) + } else { + Some(CmdResult::Relaunch( + format!("{} {keyword} {t}", self.name), + std::mem::take(&mut self.selected), + )) + } + } None => { - let up = t.to_uppercase(); - let Some((_, keyword, value_prompt)) = - self.options.iter().find(|(_, k, _)| k.eq_ignore_ascii_case(&up)) + let Some((_, keyword, value_prompt)) = match_cmd_option(&self.options, t) else { // Unknown verb — consumed, keep prompting (`None` would // feed the same text to the command a second time). @@ -798,7 +830,16 @@ impl CadCommand for SelectThenKeywordCommand { self.pending = Some((keyword, vp)); Some(CmdResult::NeedPoint) } - None => Some(CmdResult::Dispatch(format!("{} {keyword}", self.name))), + None => { + if self.selected.is_empty() { + Some(CmdResult::Dispatch(format!("{} {keyword}", self.name))) + } else { + Some(CmdResult::Relaunch( + format!("{} {keyword}", self.name), + std::mem::take(&mut self.selected), + )) + } + } } } } diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index c9d6a894..a2902bd6 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3070,6 +3070,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "those objects" => Some(("modal", "those-objects")), "units" => Some(("common", "units")), "{cmd}: cannot create folder ({e})." => Some(("file", "cmd-cannot-create-folder-e")), + "{cmd}: no hatch objects." => Some(("view", "cmd-no-hatch-objects")), "{cmd}: no text or dimension objects." => Some(("view", "cmd-no-text-or-dimension-objects")), "{cmd}: not yet implemented." => Some(("view", "cmd-not-yet-implemented")), "{cmd}: packaged {copied} file(s) into {}" => Some(("file", "cmd-packaged-copied-file-s-into")), diff --git a/src/scene/entity.rs b/src/scene/entity.rs index fb5e60f6..a257971e 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -2399,8 +2399,6 @@ impl Scene { let mut bits = 0; if is_outer { bits |= acadrust::entities::hatch::BoundaryPathFlags::OUTERMOST.bits(); - } - if !handles.is_empty() { bits |= acadrust::entities::hatch::BoundaryPathFlags::EXTERNAL.bits(); } let mut path = BoundaryPath::with_flags( diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 7f63fbe9..d887e77e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -5458,6 +5458,11 @@ impl Scene { .collect() } + /// Invalidate only the draw-order depth cache without dropping geometry tessellation. + pub fn invalidate_draw_depth(&self) { + *self.draw_depth_cache.borrow_mut() = None; + } + /// Per-entity stable draw-order depth, keyed by entity handle value. /// A full build assigns sparse labels in effective draw order. Incremental /// Add/Remove then changes only the named handle: existing siblings retain diff --git a/tests/block_hatch_export.rs b/tests/block_hatch_export.rs index 4aad9d9f..01f0a0f8 100644 --- a/tests/block_hatch_export.rs +++ b/tests/block_hatch_export.rs @@ -82,7 +82,7 @@ fn block_internal_hatch_reaches_export() { .expect("wrap hatch into a block + insert"); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let blue = hatches.iter().filter(|m| is_blue(&m.color)).count(); assert!( blue > 0, @@ -103,7 +103,7 @@ fn pattern_hatch_uses_stored_line_spacing() { scene.add_entity(EntityType::Hatch(ansi31_stored(0.5, 10.0))); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -144,7 +144,7 @@ fn far_from_origin_pattern_hatch_still_fills() { scene.add_entity(EntityType::Hatch(ansi31_stored_at(0.3, 1.0, 4000.0, 0.0))); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -189,7 +189,7 @@ fn textbox_boundary_path_is_not_filled() { scene.add_entity(EntityType::Hatch(hatch)); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches.first().expect("hatch present"); // The boundary must not extend into the 200x50 TEXTBOX rectangle. let max_x = m @@ -244,8 +244,12 @@ fn app_created_hatch_roundtrips_catalog_spacing() { world_origin: [0.0, 0.0], boundary: Arc::new(boundary), boundary_wcs: None, + fill_plane: None, + fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: entry.gpu.clone(), name: "ANSI31".into(), color: [0.75, 0.75, 0.75, 0.85], @@ -258,7 +262,7 @@ fn app_created_hatch_roundtrips_catalog_spacing() { scene.add_hatch(model, None, None); scene.populate_hatches_from_document(); - let hatches = scene.paper_canvas_hatches(); + let hatches = scene.paper_plot_hatches(); let m = hatches .iter() .find(|m| matches!(m.pattern, HatchPattern::Pattern(_))) @@ -298,8 +302,12 @@ fn nested_hatch_serializes_only_outer_as_external() { world_origin: [0.0, 0.0], boundary: Arc::new(boundary_f32), boundary_wcs: Some(Arc::new(wcs)), + fill_plane: None, + fill_plane_boundary: None, boundary_exterior: None, boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, pattern: HatchPattern::Solid, name: "SOLID".into(), color: [0.45, 0.45, 0.45, 0.60], From 7c14c31f257cc32de5bfe2f38d1ab03d372b94ef Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 23 Aug 2026 17:12:24 +0100 Subject: [PATCH 11/98] fix(draworder): distinct ordered sort keys for multi-select and send-to-back groups --- src/app/commands/view.rs | 343 +++++++++++++++++++++++++++++++++------ 1 file changed, 296 insertions(+), 47 deletions(-) diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index 0e1779ca..bfa74532 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -872,6 +872,18 @@ impl OpenCADStudio { min_eff = 1; } + // Compute all sort-key assignments before any mutable step: + // the exhausted-floor path needs one more read-only pass over + // entities to find colliding siblings to lift. + let assigns = assign_back_group_keys( + doc_ref, + block_handle, + &hatches_to_move, + min_eff, + overrides.as_ref(), + &locked_layers, + ); + // 4. Ultra-fast targeted Delta Undo (snapshots ONLY the SortEntitiesTable, zero full-drawing clone). let pending_delta = self.begin_undo(i, "DRAWORDER", hatches_to_move.len(), true); @@ -894,9 +906,8 @@ impl OpenCADStudio { if let Some(ObjectType::SortEntitiesTable(table)) = self.tabs[i].scene.document.objects.get_mut(&th) { - for (k, h) in hatches_to_move.iter().enumerate() { - let sort = min_eff.saturating_sub(1 + k as u64).max(1); - table.add_entry(*h, acadrust::Handle::new(sort)); + for (h, sort) in &assigns { + table.add_entry(*h, acadrust::Handle::new(*sort)); } } @@ -974,6 +985,7 @@ impl OpenCADStudio { // entities land strictly above/below every sibling — // including ones not yet in the table, which sort by // their own handle. (min_eff, max_eff) over siblings. + let mut back_assigns: Option> = None; let fb_baseline: Option<(u64, u64)> = if to_front_opt.is_some() { let selected_set: rustc_hash::FxHashSet = selected.iter().map(|h| h.value()).collect(); @@ -1017,6 +1029,24 @@ impl OpenCADStudio { if min_eff == u64::MAX { min_eff = 1; } + if to_front_opt == Some(false) { + // Same exhausted-floor handling as HATCHTOBACK: + // never clamp the group onto tied keys. + let locked_layers: rustc_hash::FxHashSet<&str> = doc_ref + .layers + .iter() + .filter(|l| l.is_locked()) + .map(|l| l.name.as_str()) + .collect(); + back_assigns = Some(assign_back_group_keys( + doc_ref, + block_handle, + &selected, + min_eff, + Some(&overrides), + &locked_layers, + )); + } Some((min_eff, max_eff)) } else { None @@ -1034,28 +1064,6 @@ impl OpenCADStudio { None } }); - let get_or_create = - |doc: &mut acadrust::CadDocument, block_handle| -> acadrust::Handle { - if let Some(th) = doc.objects.iter().find_map(|(h, obj)| { - if let ObjectType::SortEntitiesTable(t) = obj { - if t.block_owner_handle == block_handle { - Some(*h) - } else { - None - } - } else { - None - } - }) { - th - } else { - let nh = acadrust::Handle::new(doc.next_handle()); - let mut table = SortEntitiesTable::for_block(block_handle); - table.handle = nh; - doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); - nh - } - }; let th = table_handle.unwrap_or_else(|| { let nh = acadrust::Handle::new(doc.next_handle()); let mut table = SortEntitiesTable::for_block(block_handle); @@ -1063,24 +1071,37 @@ impl OpenCADStudio { doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); nh }); - let _ = get_or_create; // suppress unused warning if let Some(ObjectType::SortEntitiesTable(table)) = doc.objects.get_mut(&th) { if let Some((above, target)) = relative_target { - // move_above/move_below read the target's sort - // handle from the table and no-op when it is - // absent. A reference object that was never - // reordered isn't in the table yet, so seed it - // with its own handle as the implicit sort key. - if !table.contains(target) { - table.add_entry(target, target); - } - for h in &selected { - if above { - table.move_above(*h, target); - } else { - table.move_below(*h, target); + // move_above/move_below recompute target±1 per + // call, so looping them over N selected entities + // ties the whole group onto one key. Read the + // reference key once and hand out per-index + // keys instead, keeping the moved entities a + // distinct, selection-ordered block adjacent to + // the reference. + let target_sort = match table.get_sort_handle(target) { + Some(h) => h.value(), + None => { + // A reference object that was never + // reordered isn't in the table yet; + // seed it with its own handle as the + // implicit sort key. + table.add_entry(target, target); + table + .get_sort_handle(target) + .map_or(target.value(), |h| h.value()) } + }; + for (k, h) in selected.iter().enumerate() { + let offset = 1 + k as u64; + let sort = if above { + target_sort.saturating_add(offset) + } else { + target_sort.saturating_sub(offset).max(1) + }; + table.add_entry(*h, acadrust::Handle::new(sort)); } let rel = if above { "above" } else { "below" }; self.command_line.push_info(crate::tf!( @@ -1090,14 +1111,16 @@ impl OpenCADStudio { target.value() ).as_ref()); } else if let Some(to_front) = to_front_opt { - let (min_eff, max_eff) = fb_baseline.unwrap_or((1, 0)); - for (k, h) in selected.iter().enumerate() { - let sort = if to_front { - max_eff.saturating_add(1 + k as u64) - } else { - min_eff.saturating_sub(1 + k as u64).max(1) - }; - table.add_entry(*h, acadrust::Handle::new(sort)); + if to_front { + let (_, max_eff) = fb_baseline.unwrap_or((1, 0)); + for (k, h) in selected.iter().enumerate() { + let sort = max_eff.saturating_add(1 + k as u64); + table.add_entry(*h, acadrust::Handle::new(sort)); + } + } else if let Some(assigns) = &back_assigns { + for (h, sort) in assigns { + table.add_entry(*h, acadrust::Handle::new(*sort)); + } } let dir = if to_front { "front" } else { "back" }; self.command_line.push_info(crate::tf!( @@ -1378,6 +1401,65 @@ impl CadCommand for DrawOrderCommand { } } +/// Sort-key assignments sending `group` to the back of the active space. +/// +/// Normal case: the floor is the lowest effective sort key among non-moved +/// siblings, and there are enough unused slots below it — hand them out in +/// ascending order so the group lands as one block strictly behind everything +/// else with its internal stacking order preserved. +/// +/// Exhausted case (`floor <= group.len()`, e.g. a sibling already pinned at +/// key 1 or 2): decrementing would clamp every member onto one tied key via +/// `.max(1)`. Instead pin the group to keys 1..=n and lift every other +/// in-space sibling whose effective key collides with that range above it, +/// shifted by `n` so lifted siblings keep their relative order. Locked-layer +/// entities are never moved or lifted. +fn assign_back_group_keys( + doc: &acadrust::CadDocument, + block_handle: acadrust::Handle, + group: &[acadrust::Handle], + floor: u64, + overrides: Option<&rustc_hash::FxHashMap>, + locked_layers: &rustc_hash::FxHashSet<&str>, +) -> Vec<(acadrust::Handle, u64)> { + let n = group.len() as u64; + let mut out: Vec<(acadrust::Handle, u64)> = Vec::with_capacity(group.len()); + if n == 0 { + return out; + } + if floor > n { + for (k, h) in group.iter().enumerate() { + out.push((*h, floor - n + k as u64)); + } + return out; + } + for (k, h) in group.iter().enumerate() { + out.push((*h, 1 + k as u64)); + } + let moved: rustc_hash::FxHashSet = group.iter().map(|h| h.value()).collect(); + let has_locked = !locked_layers.is_empty(); + let mut lifts: Vec<(acadrust::Handle, u64)> = Vec::new(); + for e in doc.entities() { + let c = e.common(); + if c.owner_handle != block_handle && !c.owner_handle.is_null() { + continue; + } + let hv = c.handle.value(); + if moved.contains(&hv) { + continue; + } + if has_locked && locked_layers.contains(c.layer.as_str()) { + continue; + } + let eff = overrides.map_or(hv, |m| m.get(&hv).copied().unwrap_or(hv)); + if eff <= n { + lifts.push((c.handle, eff.saturating_add(n))); + } + } + out.extend(lifts); + out +} + #[cfg(test)] mod tests { use super::*; @@ -1635,6 +1717,73 @@ mod tests { assert!(hatch_sort < line_sort); } + // HATCHTOBACK must preserve the moved group's internal stacking order. + // Written while auditing the descending key assignment, which silently + // REVERSES hatch-to-hatch order; this test fails while that holds. + #[test] + fn hatchtoback_preserves_hatch_stacking_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch3 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + let _ = app.run_command_line("HATCHTOBACK"); + + let entries = effective_sort_map(&app); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + let s1 = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let s2 = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + let s3 = entries.get(&h_hatch3.value()).copied().unwrap_or(h_hatch3.value()); + + assert!(s3 > s2 && s2 > s1, "stacking order must be preserved, got {s1}, {s2}, {s3}"); + assert!(s1 < line_sort && s2 < line_sort && s3 < line_sort, "all hatches must stay behind the line"); + } + + // When the key space below the floor is exhausted (an entity already sits + // at a very low sort key), HATCHTOBACK must not clamp every hatch onto one + // tied key: hatches stay pairwise distinct and no sibling may sink below + // them. Fails while `.max(1)` clamping is in place. + #[test] + fn hatchtoback_exhausted_floor_keeps_hatches_distinct_and_behind() { + let mut app = fresh_app(); + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let h_line = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch3 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + // Pin the line to sort key 2: only ONE slot below the floor exists, + // but three hatches need to fit behind it. + { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let doc = &mut app.tabs[i].scene.document; + let nh = acadrust::Handle::new(doc.next_handle()); + let mut table = SortEntitiesTable::for_block(block_handle); + table.handle = nh; + table.add_entry(h_line, acadrust::Handle::new(2)); + doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); + } + + let _ = app.run_command_line("HATCHTOBACK"); + + let entries = effective_sort_map(&app); + let line_sort = entries.get(&h_line.value()).copied().unwrap_or(h_line.value()); + let s1 = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let s2 = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + let s3 = entries.get(&h_hatch3.value()).copied().unwrap_or(h_hatch3.value()); + + assert_ne!(s1, s2, "hatches must not tie on one clamped key"); + assert_ne!(s1, s3, "hatches must not tie on one clamped key"); + assert_ne!(s2, s3, "hatches must not tie on one clamped key"); + assert!( + line_sort > s1.max(s2).max(s3), + "the pinned line ({line_sort}) must still render above every hatch ({s1}, {s2}, {s3})" + ); + } + #[test] fn hatchtoback_undo_restores_draw_order() { let mut app = fresh_app(); @@ -1726,6 +1875,106 @@ mod tests { ); } + // Regression test for a suspected multi-select tie in DRAWORDER + // ABOVE/UNDER: the loop called move_above/move_below per entity, and each + // call recomputes target±1, so N selected entities should all collapse to + // one key. This test must fail while that bug exists. + // Same floor-exhaustion scenario as hatchtoback_exhausted_floor_…, but + // through the interactive DRAWORDER BACK path: two selected lines must + // land distinct and strictly behind a sibling pinned at key 2, instead of + // both clamping onto key 1. + #[test] + fn draworder_back_exhausted_floor_keeps_distinct_and_behind() { + let mut app = fresh_app(); + let i = app.active_tab; + let block_handle = app.tabs[i].scene.current_layout_block_handle_pub(); + let h_pinned = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_line1 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_line2 = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + + { + use acadrust::objects::{ObjectType, SortEntitiesTable}; + let doc = &mut app.tabs[i].scene.document; + let nh = acadrust::Handle::new(doc.next_handle()); + let mut table = SortEntitiesTable::for_block(block_handle); + table.handle = nh; + table.add_entry(h_pinned, acadrust::Handle::new(2)); + doc.objects.insert(nh, ObjectType::SortEntitiesTable(table)); + } + + app.tabs[i] + .scene + .replace_selection([h_line1, h_line2].into_iter().collect()); + let _ = app.run_command_line("DRAWORDER BACK"); + + let entries = effective_sort_map(&app); + let pinned_sort = entries.get(&h_pinned.value()).copied().unwrap_or(h_pinned.value()); + let s1 = entries.get(&h_line1.value()).copied().unwrap_or(h_line1.value()); + let s2 = entries.get(&h_line2.value()).copied().unwrap_or(h_line2.value()); + + assert_ne!(s1, s2, "BACK must not clamp both selected entities onto one tied key"); + assert!( + pinned_sort > s1.max(s2), + "pinned sibling ({pinned_sort}) must stay above the moved pair ({s1}, {s2})" + ); + } + + #[test] + fn draworder_above_multi_select_keeps_distinct_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i] + .scene + .replace_selection([h_hatch1, h_hatch2].into_iter().collect()); + let cmd = format!("DRAWORDER ABOVE {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + let hatch1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let hatch2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + + assert!(hatch1_sort > ref_sort, "hatch 1 ({hatch1_sort}) must be above reference ({ref_sort})"); + assert!(hatch2_sort > ref_sort, "hatch 2 ({hatch2_sort}) must be above reference ({ref_sort})"); + assert_ne!(hatch1_sort, hatch2_sort, "multi-select ABOVE must not tie selected entities"); + assert!( + hatch2_sort > hatch1_sort, + "selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})" + ); + } + + #[test] + fn draworder_under_multi_select_keeps_distinct_order() { + let mut app = fresh_app(); + let i = app.active_tab; + let h_ref = app.tabs[i].scene.add_entity_clone(EntityType::Line(Default::default())); + let h_hatch1 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + let h_hatch2 = app.tabs[i].scene.add_entity_clone(EntityType::Hatch(Default::default())); + + app.tabs[i] + .scene + .replace_selection([h_hatch1, h_hatch2].into_iter().collect()); + let cmd = format!("DRAWORDER UNDER {:x}", h_ref.value()); + let _ = app.run_command_line(&cmd); + + let entries = effective_sort_map(&app); + let ref_sort = entries.get(&h_ref.value()).copied().unwrap_or(h_ref.value()); + let hatch1_sort = entries.get(&h_hatch1.value()).copied().unwrap_or(h_hatch1.value()); + let hatch2_sort = entries.get(&h_hatch2.value()).copied().unwrap_or(h_hatch2.value()); + + assert!(hatch1_sort < ref_sort, "hatch 1 ({hatch1_sort}) must be under reference ({ref_sort})"); + assert!(hatch2_sort < ref_sort, "hatch 2 ({hatch2_sort}) must be under reference ({ref_sort})"); + assert_ne!(hatch1_sort, hatch2_sort, "multi-select UNDER must not tie selected entities"); + assert!( + hatch1_sort > hatch2_sort, + "selection order must be preserved within the moved group ({hatch1_sort}, {hatch2_sort})" + ); + } + #[test] fn draworder_front_moves_hatch_in_front_of_all() { let mut app = fresh_app(); From fb2ea1e831ce251236f1d0487b3bddb8d491b00b Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 23 Aug 2026 17:12:24 +0100 Subject: [PATCH 12/98] test(draworder): large-drawing latency benchmark --- src/app/commands/view.rs | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index bfa74532..fa4a223b 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -1467,6 +1467,74 @@ mod tests { use acadrust::objects::ObjectType; use acadrust::EntityType; + // Benchmark, not a correctness test: measures end-to-end latency of + // HATCHTOBACK and interactive DRAWORDER BACK on a large synthetic drawing. + // Ignored by default; A/B across commits with: + // BENCH_LINES=100000 BENCH_HATCHES=350 BENCH_RUNS=5 \ + // cargo test --release --lib bench_draworder_large_drawing -- --ignored --nocapture + #[test] + #[ignore] + fn bench_draworder_large_drawing() { + let lines_n: usize = std::env::var("BENCH_LINES").ok().and_then(|v| v.parse().ok()).unwrap_or(100_000); + let hatches_n: usize = std::env::var("BENCH_HATCHES").ok().and_then(|v| v.parse().ok()).unwrap_or(350); + let runs: usize = std::env::var("BENCH_RUNS").ok().and_then(|v| v.parse().ok()).unwrap_or(5); + let sel_n: usize = std::env::var("BENCH_SEL").ok().and_then(|v| v.parse().ok()).unwrap_or(100); + + fn median(v: &mut [f64]) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] + } + + // --- HATCHTOBACK --- + let mut hb_ms: Vec = Vec::new(); + for _ in 0..runs { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let t0 = std::time::Instant::now(); + let lines: Vec = + (0..lines_n).map(|_| EntityType::Line(Default::default())).collect(); + let _ = app.tabs[i].scene.add_entities(lines); + let hatches: Vec = + (0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect(); + app.tabs[i].scene.add_entities(hatches); + eprintln!( + "[bench] setup ({} lines + {} hatches): {:.1} ms", + lines_n, + hatches_n, + t0.elapsed().as_secs_f64() * 1e3 + ); + let t1 = std::time::Instant::now(); + let _ = app.run_command_line("HATCHTOBACK"); + hb_ms.push(t1.elapsed().as_secs_f64() * 1e3); + } + eprintln!("[bench] HATCHTOBACK median: {:.3} ms over {} runs", median(&mut hb_ms), runs); + + // --- DRAWORDER BACK (interactive path) --- + let mut db_ms: Vec = Vec::new(); + for _ in 0..runs { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let lines: Vec = + (0..lines_n).map(|_| EntityType::Line(Default::default())).collect(); + let _ = app.tabs[i].scene.add_entities(lines); + let hatches: Vec = + (0..hatches_n).map(|_| EntityType::Hatch(Default::default())).collect(); + let handles = app.tabs[i].scene.add_entities(hatches); + app.tabs[i] + .scene + .replace_selection(handles.into_iter().take(sel_n).collect()); + let t1 = std::time::Instant::now(); + let _ = app.run_command_line("DRAWORDER BACK"); + db_ms.push(t1.elapsed().as_secs_f64() * 1e3); + } + eprintln!( + "[bench] DRAWORDER BACK ({} selected) median: {:.3} ms over {} runs", + sel_n, median(&mut db_ms), runs + ); + } + fn fresh_app() -> OpenCADStudio { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); From 86a29b4b976277151c77eaaa3270f1180032d2cc Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:27:30 +0200 Subject: [PATCH 13/98] feat(properties): show XDATA records in Properties panel --- locales/en-US/opencadstudio.ftl | 1 + src/app/properties.rs | 47 +++++++++++++++++++++++++++++++++ src/locale_catalog.rs | 1 + 3 files changed, 49 insertions(+) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 671366f4..99040fac 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -2563,6 +2563,7 @@ properties = .ext-line-lineweight = Ext line lineweight .ext-line-offset = Ext line offset .extended-entity = Extended Entity + .extended-data = Extended Data .extended-lighting = Extended Lighting .extents-max = Extents Max .extents-min = Extents Min diff --git a/src/app/properties.rs b/src/app/properties.rs index abb9b0bc..a51ed03a 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -27,6 +27,27 @@ fn visual_style_properties_text(style: &acadrust::objects::VisualStyle) -> Strin .join("\n") } +/// Format an XDATA value for display in the Properties panel. +fn format_xdata_value(value: &acadrust::xdata::XDataValue) -> String { + use acadrust::xdata::XDataValue; + match value { + XDataValue::String(s) => s.clone(), + XDataValue::ControlString(s) => format!("ControlString: {s}"), + XDataValue::LayerName(s) => format!("Layer: {s}"), + XDataValue::BinaryData(b) => format!("Binary: {} bytes", b.len()), + XDataValue::Handle(h) => format!("Handle: {:X}", h.value()), + XDataValue::Point3D(v) + | XDataValue::Position3D(v) + | XDataValue::Displacement3D(v) + | XDataValue::Direction3D(v) => format!("{:.6}, {:.6}, {:.6}", v.x, v.y, v.z), + XDataValue::Real(r) | XDataValue::Distance(r) | XDataValue::ScaleFactor(r) => { + format!("{r}") + } + XDataValue::Integer16(i) => format!("{i}"), + XDataValue::Integer32(i) => format!("{i}"), + } +} + impl OpenCADStudio { /// Rebuild the PropertiesPanel from the current entity selection. /// Preserves UI state (open pickers, edit buffer) across refreshes. @@ -613,6 +634,32 @@ impl OpenCADStudio { entity, )); + // Extended Data (XDATA) section: show attached app records. + { + use crate::entities::common::ro_prop; + let xd = &source_entity.common().extended_data; + if !xd.is_empty() { + let mut xd_props = Vec::new(); + for rec in xd.records() { + let value_text = rec + .values + .iter() + .map(format_xdata_value) + .collect::>() + .join(", "); + xd_props.push(ro_prop( + &rec.application_name, + "xdata_value", + value_text, + )); + } + sections.push(crate::scene::model::object::PropSection { + title: t!("Extended Data").into_owned(), + props: xd_props, + }); + } + } + // Uniform-scale checkbox for block references (#427): // while the three scale factors are equal (and the user // hasn't opted into per-axis editing) the panel shows one diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index c9d6a894..eeb33ea4 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -1006,6 +1006,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Ext line offset" => Some(("properties", "ext-line-offset")), "Ext line weight (DIMLWE)" => Some(("draw", "ext-line-weight-dimlwe")), "Extend" => Some(("modify", "extend")), + "Extended Data" => Some(("properties", "extended-data")), "Extended Entity" => Some(("properties", "extended-entity")), "Extended Lighting" => Some(("properties", "extended-lighting")), "Extension" => Some(("common", "extension")), From 06cd0ec6a9665f67f86ed7d835172fc0e3d8bb91 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 22:53:27 +0300 Subject: [PATCH 14/98] feat(model): add exact solid modelling tools --- Cargo.lock | 4 +- Cargo.toml | 4 +- assets/icons/extrude.svg | 4 + assets/icons/loft.svg | 4 + assets/icons/polysolid.svg | 4 + assets/icons/presspull.svg | 4 + assets/icons/pyramid3d.svg | 4 + assets/icons/revolve.svg | 4 + assets/icons/sweep.svg | 4 + crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- locales/ar-SA/opencadstudio.ftl | 11 +- locales/bg-BG/opencadstudio.ftl | 11 +- locales/cs-CZ/opencadstudio.ftl | 11 +- locales/de-DE/opencadstudio.ftl | 11 +- locales/en-US/opencadstudio.ftl | 11 +- locales/es-ES/opencadstudio.ftl | 11 +- locales/fi-FI/opencadstudio.ftl | 11 +- locales/fr-FR/opencadstudio.ftl | 11 +- locales/hi-IN/opencadstudio.ftl | 11 +- locales/hu-HU/opencadstudio.ftl | 11 +- locales/it-IT/opencadstudio.ftl | 11 +- locales/ja-JP/opencadstudio.ftl | 11 +- locales/ko-KR/opencadstudio.ftl | 11 +- locales/nl-NL/opencadstudio.ftl | 11 +- locales/pl-PL/opencadstudio.ftl | 11 +- locales/pt-BR/opencadstudio.ftl | 11 +- locales/ru-RU/opencadstudio.ftl | 11 +- locales/tr-TR/opencadstudio.ftl | 11 +- locales/zh-CN/opencadstudio.ftl | 11 +- locales/zh-TW/opencadstudio.ftl | 11 +- src/app/command_driver.rs | 277 ++++++++++++-------------- src/app/commands/dim.rs | 38 ++++ src/app/commands/display.rs | 44 +++- src/app/commands/draw.rs | 17 +- src/app/model_ops.rs | 283 ++++++++++++++++++-------- src/app/update/file.rs | 2 +- src/app/update/viewport.rs | 38 +++- src/command.rs | 30 ++- src/locale_catalog.rs | 9 + src/modules/insert/solid3d_cmds.rs | 202 ++++++++++++++++--- src/modules/model/boolean_cmd.rs | 4 +- src/modules/model/edge_cmd.rs | 146 ++++++++++++++ src/modules/model/mod.rs | 54 +++-- src/modules/model/primitive_cmd.rs | 100 ++++++---- src/scene/convert/acis_export.rs | 15 +- src/scene/mod.rs | 63 ++++-- src/scene/model/solid_history.rs | 78 +------- src/scene/model/solid_model.rs | 85 ++++++-- src/scene/model/sweep_model.rs | 310 ++++++++++++++++++++--------- src/scene/modify.rs | 4 +- 51 files changed, 1487 insertions(+), 574 deletions(-) create mode 100644 assets/icons/extrude.svg create mode 100644 assets/icons/loft.svg create mode 100644 assets/icons/polysolid.svg create mode 100644 assets/icons/presspull.svg create mode 100644 assets/icons/pyramid3d.svg create mode 100644 assets/icons/revolve.svg create mode 100644 assets/icons/sweep.svg create mode 100644 src/modules/model/edge_cmd.rs diff --git a/Cargo.lock b/Cargo.lock index 32a1413b..189c1b22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=db73dab#db73dab540dbfb690c93f4416014262e6cf7bf42" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=467c3fd#467c3fd02f419ab5af4030691699b34b25d2f51a" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=76de3b3#76de3b35e55a45f3ffb813c499cf3affe176351d" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index ec1734bb..4cc5ae75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,8 +27,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "467c3fd", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "76de3b3", 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/assets/icons/extrude.svg b/assets/icons/extrude.svg new file mode 100644 index 00000000..d864bce3 --- /dev/null +++ b/assets/icons/extrude.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/loft.svg b/assets/icons/loft.svg new file mode 100644 index 00000000..99e9cf62 --- /dev/null +++ b/assets/icons/loft.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/polysolid.svg b/assets/icons/polysolid.svg new file mode 100644 index 00000000..8532c376 --- /dev/null +++ b/assets/icons/polysolid.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/presspull.svg b/assets/icons/presspull.svg new file mode 100644 index 00000000..189e6909 --- /dev/null +++ b/assets/icons/presspull.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/pyramid3d.svg b/assets/icons/pyramid3d.svg new file mode 100644 index 00000000..a7d26f91 --- /dev/null +++ b/assets/icons/pyramid3d.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/revolve.svg b/assets/icons/revolve.svg new file mode 100644 index 00000000..48073ba9 --- /dev/null +++ b/assets/icons/revolve.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/sweep.svg b/assets/icons/sweep.svg new file mode 100644 index 00000000..4db7d373 --- /dev/null +++ b/assets/icons/sweep.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index bba21d9c..6f6feee5 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -14,7 +14,7 @@ serde = { version = "1", features = ["derive"] } # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -37,7 +37,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # acadrust is scanned at build time to generate the embedded type registry. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index ff034dd7..b3a052b0 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "db73dab", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index 62a03272..5c48a3ce 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = رسم .annotate = تعليقات توضيحية .insert = إدراج - .model = نموذج + .model = النمذجة .layout = تخطيط .manage = إدارة .view = عرض @@ -2047,6 +2047,15 @@ insert = .object-s-cut-to-clipboard = قُص __ocs_fmt_0__ كائن إلى الحافظة. model = + .create = إنشاء + .boolean = عمليات منطقية + .pyramid = هرم + .polysolid = مجسّم متعدد + .extrude = بثق + .revolve = تدوير + .loft = تشكيل انتقالي + .sweep = سحب على مسار + .presspull = دفع/سحب .n-specify-center-point = __ocs_arg_n__ حدد نقطة المركز: .n-specify-first-corner = __ocs_arg_n__ حدد الزاوية الأولى: .n-specify-height-enter-for-default = __ocs_arg_n__ حدد الارتفاع : diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index fe6994b8..2df4f555 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Чертане .annotate = Анотиране .insert = Вмъкване - .model = Модел + .model = Моделиране .layout = Оформление .manage = Управление .view = Изглед @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ обект(а) изрязани в клипборда. model = + .create = Създаване + .boolean = Булеви операции + .pyramid = Пирамида + .polysolid = Полисолид + .extrude = Екструдиране + .revolve = Завъртане + .loft = Лофт + .sweep = Изтегляне по траектория + .presspull = Натискане/издърпване .n-specify-center-point = __ocs_arg_n__ Посочи централна точка: .n-specify-first-corner = __ocs_arg_n__ Посочи първи ъгъл: .n-specify-height-enter-for-default = __ocs_arg_n__ Посочи височина : diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 92c95855..f3a9b7f4 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Kreslení .annotate = Poznámky .insert = Vložit - .model = Model + .model = Modelování .layout = Rozvržení .manage = Správa .view = Zobrazení @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = Objekt(y) __ocs_fmt_0__ vyříznutý do schránky. model = + .create = Vytvořit + .boolean = Booleovské operace + .pyramid = Jehlan + .polysolid = Polysolid + .extrude = Vysunutí + .revolve = Rotace + .loft = Přechod + .sweep = Tažení + .presspull = Tlačit/táhnout .n-specify-center-point = __ocs_arg_n__ Zadejte středový bod: .n-specify-first-corner = __ocs_arg_n__ Určete první roh: .n-specify-height-enter-for-default = __ocs_arg_n__ Zadejte výšku : diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 87f3b152..ac4eb8dc 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Zeichnen .annotate = Beschriften .insert = Einsatz - .model = Modell + .model = Modellierung .layout = Layout .manage = Verwalten .view = Ansicht @@ -2033,6 +2033,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ Objekt(e) in die Zwischenablage geschnitten. model = + .create = Erstellen + .boolean = Boolesche Operationen + .pyramid = Pyramide + .polysolid = Polykörper + .extrude = Extrudieren + .revolve = Drehen + .loft = Loft + .sweep = Austragen + .presspull = Drücken/Ziehen .n-specify-center-point = __ocs_arg_n__ Mittelpunkt angeben: .n-specify-first-corner = __ocs_arg_n__ Erste Ecke angeben: .n-specify-height-enter-for-default = __ocs_arg_n__ Höhe angeben : diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6386d0d6..71bd8ff2 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Draw .annotate = Annotate .insert = Insert - .model = Model + .model = Modelling .layout = Layout .manage = Manage .view = View @@ -2063,6 +2063,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ object(s) cut to clipboard. model = + .create = Create + .boolean = Boolean + .pyramid = Pyramid + .polysolid = Polysolid + .extrude = Extrude + .revolve = Revolve + .loft = Loft + .sweep = Sweep + .presspull = Presspull .n-specify-center-point = __ocs_arg_n__ Specify center point: .n-specify-first-corner = __ocs_arg_n__ Specify first corner: .n-specify-height-enter-for-default = __ocs_arg_n__ Specify height : diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 3f4a04fe..a929a1f0 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Dibujo .annotate = Anotar .insert = Insertar - .model = Modelo + .model = Modelado .layout = Presentación .manage = Administrar .view = Vista @@ -2035,6 +2035,15 @@ insert = .object-s-cut-to-clipboard = Se cortaron __ocs_fmt_0__ objetos al portapapeles. model = + .create = Crear + .boolean = Booleanas + .pyramid = Pirámide + .polysolid = Polisólido + .extrude = Extruir + .revolve = Revolucionar + .loft = Transición + .sweep = Barrido + .presspull = Presionar/tirar .n-specify-center-point = __ocs_arg_n__ Especifique el centro: .n-specify-first-corner = __ocs_arg_n__ Especifique la primera esquina: .n-specify-height-enter-for-default = __ocs_arg_n__ Especifique la altura : diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index 08717ec7..8399b91f 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Piirrä .annotate = Merkinnät .insert = Lisää - .model = Malli + .model = Mallinnus .layout = Asettelu .manage = Hallinta .view = Näkymä @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objekti(t) leikattu leikepöydälle. model = + .create = Luo + .boolean = Boolen operaatiot + .pyramid = Pyramidi + .polysolid = Monikappale + .extrude = Pursota + .revolve = Pyöräytä + .loft = Loftaus + .sweep = Pyyhkäisy + .presspull = Työnnä/vedä .n-specify-center-point = __ocs_arg_n__ Määritä keskipiste: .n-specify-first-corner = __ocs_arg_n__ Määritä ensimmäinen kulma: .n-specify-height-enter-for-default = __ocs_arg_n__ Määritä korkeus : diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index d2dbe5e9..f0ced22a 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Dessin .annotate = Annoter .insert = Insérer - .model = Modèle + .model = Modélisation .layout = Mise en page .manage = Gérer .view = Affichage @@ -2035,6 +2035,15 @@ insert = .object-s-cut-to-clipboard = Objets coupés vers le presse-papiers : __ocs_fmt_0__. model = + .create = Créer + .boolean = Opérations booléennes + .pyramid = Pyramide + .polysolid = Polysolide + .extrude = Extruder + .revolve = Révolution + .loft = Lissage + .sweep = Balayage + .presspull = Tirer-pousser .n-specify-center-point = __ocs_arg_n__ Spécifier le point central: .n-specify-first-corner = __ocs_arg_n__ Spécifiez le premier coin : .n-specify-height-enter-for-default = __ocs_arg_n__ Spécifiez la hauteur : diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index d8f40a6c..4356de5c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = ड्रॉ .annotate = एनोटेट .insert = सम्मिलित करें - .model = मॉडल + .model = मॉडलिंग .layout = लेआउट .manage = प्रबंधित करें .view = देखें @@ -2023,6 +2023,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ ऑब्जेक्ट क्लिपबोर्ड पर काटे गए। model = + .create = बनाएँ + .boolean = बूलियन संक्रियाएँ + .pyramid = पिरामिड + .polysolid = बहुखंड ठोस + .extrude = एक्सट्रूड + .revolve = घुमाएँ + .loft = लॉफ्ट + .sweep = स्वीप + .presspull = दबाएँ/खींचें .n-specify-center-point = __ocs_arg_n__ केंद्र बिंदु निर्दिष्ट करें: .n-specify-first-corner = __ocs_arg_n__ पहले कोने को निर्दिष्ट करें: .n-specify-height-enter-for-default = __ocs_arg_n__ ऊंचाई निर्दिष्ट करें के लिए दर्ज करें>: diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 0b7a8f7b..af07478f 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Rajz .annotate = Feliratozás .insert = Beszúrás - .model = Modell + .model = Modellezés .layout = Elrendezés .manage = Kezelés .view = Nézet @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objektum(ok) vágólapra vágva. model = + .create = Létrehozás + .boolean = Logikai műveletek + .pyramid = Gúla + .polysolid = Poliszilárdtest + .extrude = Kihúzás + .revolve = Forgatás + .loft = Átmenet + .sweep = Seprés + .presspull = Nyomás/húzás .n-specify-center-point = __ocs_arg_n__ Középpont megadása: .n-specify-first-corner = __ocs_arg_n__ Adja meg az első sarkot: .n-specify-height-enter-for-default = __ocs_arg_n__ Adja meg a magasságot: diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index 8c915bb8..6ab5b504 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Disegno .annotate = Annotazione .insert = Inserisci - .model = Modello + .model = Modellazione .layout = Disposizione .manage = Gestisci .view = Vista @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ oggetto/i tagliato negli appunti. model = + .create = Crea + .boolean = Operazioni booleane + .pyramid = Piramide + .polysolid = Polisolido + .extrude = Estrudi + .revolve = Rivoluzione + .loft = Loft + .sweep = Scorrimento + .presspull = Premi/tira .n-specify-center-point = __ocs_arg_n__ Specificare il punto centrale: .n-specify-first-corner = __ocs_arg_n__ Specificare il primo angolo: .n-specify-height-enter-for-default = __ocs_arg_n__ Specificare l'altezza : diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index 4abf157c..7531bc56 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 作図 .annotate = 注釈 .insert = 挿入 - .model = モデル + .model = モデリング .layout = レイアウト .manage = 管理 .view = 表示 @@ -2023,6 +2023,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 個のオブジェクトを切り取ってクリップボードに保存しました。 model = + .create = 作成 + .boolean = ブール演算 + .pyramid = ピラミッド + .polysolid = ポリソリッド + .extrude = 押し出し + .revolve = 回転 + .loft = ロフト + .sweep = スイープ + .presspull = プレスプル .n-specify-center-point = __ocs_arg_n__ 中心点を指定: .n-specify-first-corner = __ocs_arg_n__ 最初のコーナーを指定: .n-specify-height-enter-for-default = __ocs_arg_n__ 高さを指定 : diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index 9c1b5738..0fd664f8 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 그리기 .annotate = 주석 .insert = 삽입 - .model = 모델 + .model = 모델링 .layout = 배치 .manage = 관리 .view = 보기 @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 개체를 클립보드로 잘라냈습니다. model = + .create = 생성 + .boolean = 불리언 연산 + .pyramid = 피라미드 + .polysolid = 폴리솔리드 + .extrude = 돌출 + .revolve = 회전 + .loft = 로프트 + .sweep = 스윕 + .presspull = 누르기/당기기 .n-specify-center-point = __ocs_arg_n__ 중심점 지정: .n-specify-first-corner = __ocs_arg_n__ 첫 번째 구석 지정: .n-specify-height-enter-for-default = __ocs_arg_n__ 높이 지정 : diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 876a6cbf..a2abe740 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Tekenen .annotate = Annoteren .insert = Invoegen - .model = Model + .model = Modelleren .layout = Indeling .manage = Beheren .view = Beeld @@ -2035,6 +2035,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ object(en) naar het klembord geknipt. model = + .create = Maken + .boolean = Booleaanse bewerkingen + .pyramid = Piramide + .polysolid = Polysolid + .extrude = Extruderen + .revolve = Omwentelen + .loft = Loft + .sweep = Vegen + .presspull = Duwen/trekken .n-specify-center-point = __ocs_arg_n__ Centrumpunt opgeven: .n-specify-first-corner = __ocs_arg_n__ Geef eerste hoek: .n-specify-height-enter-for-default = __ocs_arg_n__ Hoogte opgeven: diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 9ac73584..9f3d339d 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Rysowanie .annotate = Opisy .insert = Wstaw - .model = Model + .model = Modelowanie .layout = Układ .manage = Zarządzaj .view = Widok @@ -2034,6 +2034,15 @@ insert = .object-s-cut-to-clipboard = Obiekt(y) __ocs_fmt_0__ wycięty do schowka. model = + .create = Utwórz + .boolean = Operacje boolowskie + .pyramid = Ostrosłup + .polysolid = Bryła wielosegmentowa + .extrude = Wyciągnij + .revolve = Obróć + .loft = Loft + .sweep = Przeciągnij + .presspull = Naciśnij/wyciągnij .n-specify-center-point = __ocs_arg_n__ Określ punkt środkowy: .n-specify-first-corner = __ocs_arg_n__ Określ pierwszy narożnik: .n-specify-height-enter-for-default = __ocs_arg_n__ Podaj wysokość : diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 4b70122d..0c8d9886 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Desenhar .annotate = Anotar .insert = Inserir - .model = Modelo + .model = Modelagem .layout = Layout .manage = Gerenciar .view = Exibir @@ -2035,6 +2035,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ objetos recortados para a área de transferência. model = + .create = Criar + .boolean = Operações booleanas + .pyramid = Pirâmide + .polysolid = Polissólido + .extrude = Extrusão + .revolve = Revolução + .loft = Loft + .sweep = Varredura + .presspull = Pressionar/puxar .n-specify-center-point = __ocs_arg_n__ Especifique o ponto central: .n-specify-first-corner = __ocs_arg_n__ Especifique o primeiro canto: .n-specify-height-enter-for-default = __ocs_arg_n__ Especifique a altura : diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 957709db..37223a15 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Начертить .annotate = Аннотации .insert = Вставка - .model = Модель + .model = Моделирование .layout = Расположение .manage = Управление .view = Просмотр @@ -2038,6 +2038,15 @@ insert = .object-s-cut-to-clipboard = В буфер обмена вырезано объектов: __ocs_fmt_0__. model = + .create = Создание + .boolean = Булевы операции + .pyramid = Пирамида + .polysolid = Политело + .extrude = Выдавливание + .revolve = Вращение + .loft = Лофт + .sweep = Сдвиг по траектории + .presspull = Нажать/вытянуть .n-specify-center-point = __ocs_arg_n__ Укажите центральную точку: .n-specify-first-corner = __ocs_arg_n__ Укажите первый угол: .n-specify-height-enter-for-default = __ocs_arg_n__ Укажите высоту <Введите по умолчанию>: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 4f2d10e7..2f44e370 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = Çizim .annotate = Açıklama .insert = Ekle - .model = Model + .model = Modelleme .layout = Yerleşim .manage = Yönet .view = Görünüm @@ -2023,6 +2023,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ nesne panoya kesildi. model = + .create = Oluştur + .boolean = Boole işlemleri + .pyramid = Piramit + .polysolid = Çoklu katı + .extrude = Katılaştır + .revolve = Döndür + .loft = Kesitler arası + .sweep = Yol boyunca süpür + .presspull = İt/Çek .n-specify-center-point = __ocs_arg_n__ Merkez noktasını belirtin: .n-specify-first-corner = __ocs_arg_n__ İlk köşeyi belirtin: .n-specify-height-enter-for-default = __ocs_arg_n__ Yüksekliği belirtin : diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index 0fb98a5a..daec9ff0 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 绘制 .annotate = 注释 .insert = 插入 - .model = 模型 + .model = 建模 .layout = 布局 .manage = 管理 .view = 视图 @@ -2018,6 +2018,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 对象切入剪贴板。 model = + .create = 创建 + .boolean = 布尔运算 + .pyramid = 棱锥 + .polysolid = 多段体 + .extrude = 拉伸 + .revolve = 旋转 + .loft = 放样 + .sweep = 扫掠 + .presspull = 按拉 .n-specify-center-point = __ocs_arg_n__ 指定中心点: .n-specify-first-corner = __ocs_arg_n__ 指定第一个角点: .n-specify-height-enter-for-default = __ocs_arg_n__ 指定高度(按 Enter 使用默认值): diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index 21f275b3..460ed3f3 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -30,7 +30,7 @@ ribbon-tab = .draw = 繪製 .annotate = 註解 .insert = 插入 - .model = 模型 + .model = 建模 .layout = 配置 .manage = 管理 .view = 視圖 @@ -2030,6 +2030,15 @@ insert = .object-s-cut-to-clipboard = __ocs_fmt_0__ 物件切入剪貼簿。 model = + .create = 建立 + .boolean = 布林運算 + .pyramid = 角錐體 + .polysolid = 多段實體 + .extrude = 擠出 + .revolve = 旋轉 + .loft = 放樣 + .sweep = 掃掠 + .presspull = 按拉 .n-specify-center-point = __ocs_arg_n__ 指定中心點: .n-specify-first-corner = __ocs_arg_n__ 指定第一個角點: .n-specify-height-enter-for-default = __ocs_arg_n__ 指定高度(按 Enter 使用預設值): diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e84005b7..68fbe0fe 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1204,15 +1204,17 @@ impl OpenCADStudio { } => { let label = self.history_label_from_active_cmd(i, "SOLID"); let pending = self.begin_undo(i, label, 1, true); - self.add_solid_model(entity, *solid, history); - self.tabs[i].dirty = true; + let handle = self.add_solid_model(entity, *solid, history); + if !handle.is_null() { + self.tabs[i].dirty = true; + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + } self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } } CmdResult::CommitAndEditText(entity) => { let label = self.history_label_from_active_cmd(i, "ENTITY"); @@ -3001,80 +3003,32 @@ impl OpenCADStudio { self.commit_undo_delta(i, pending); } } - // ── Solid3D creation (BOX / SPHERE / CYLINDER) ──────────────── - CmdResult::CommitSolid3D { mesh_fn } => { - use crate::modules::insert::solid3d_cmds::empty_solid3d; - let pending = self.begin_undo(i, "SOLID3D", 1, true); - let entity = empty_solid3d(); - let handle = self.tabs[i].scene.add_entity(entity); - if !handle.is_null() { - let name = format!("{}", handle.value()); - let color = [0.6f32, 0.6, 0.8, 1.0]; // default colour; command embedded it - let _ = color; // color is captured inside mesh_fn - if let Some(mesh) = mesh_fn(name) { - let set = crate::scene::MeshLodSet::from_single(mesh); - if let Some(acadrust::EntityType::Solid3D(entity)) = - self.tabs[i].scene.document.get_entity_mut(handle) - { - let center = set.metrics.centroid; - entity.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - self.tabs[i].scene.meshes.insert(handle, set); - } - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("Solid created.").as_ref()); - } - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.tabs[i].scene.clear_preview_wire(); - self.restore_pre_cmd_tangent(); - } - // ── EXTRUDE ──────────────────────────────────────────────────── CmdResult::ExtrudeEntity { handle, height, - color, + color: _, } => { if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_model, sweep_model}; + use crate::scene::model::sweep_model; let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(entity) = entity_opt { - // The kernel sweeps the profile into analytic surfaces — - // a straight run becomes a plane and an arc a cylinder — - // so the solid saves as exact ACIS rather than facets. - let result = sweep_model::extruded(&entity, height as f64) - .and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body))); - if let Some((mesh, solid)) = result { - let history = crate::scene::model::solid_history::extrusion_op( - &entity, - height as f64, - ); + if let Some(solid) = sweep_model::extruded(&entity, height) { + let history = crate::scene::model::solid_history::brep_op(&solid); let pending = self.begin_undo(i, "EXTRUDE", 1, true); - let mut s3d = empty_solid3d(); - if let acadrust::EntityType::Solid3D(inner) = &mut s3d { - inner.wires = solid_model::edge_wires(&solid); - } - let new_handle = self.tabs[i].scene.add_entity(s3d); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - self.tabs[i].scene.register_solid_model(new_handle, solid); - let _ = mesh; - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("EXTRUDE: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("EXTRUDE: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("EXTRUDE: could not build profile. Select a closed 2D entity (Circle, LwPolyline, etc.).").as_ref()); @@ -3088,26 +3042,80 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } + CmdResult::PresspullEntity { + handle, + pick, + distance, + drag, + color, + } => { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + let task = self.solid_face_presspull(handle, pick, distance, drag); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + if self.reject_locked_edit(i, handle) { + self.tabs[i].active_cmd = None; + return Task::none(); + } + use crate::modules::insert::solid3d_cmds::empty_solid3d; + use crate::scene::model::{solid_history, sweep_model}; + + let result = self.tabs[i].scene.document.get_entity(handle).and_then(|entity| { + let distance = match drag { + Some(point) => sweep_model::projected_drag(entity, pick, point)?, + None => distance, + }; + sweep_model::extruded(entity, distance) + }); + if let Some(solid) = result { + let pending = self.begin_undo(i, "PRESSPULL", 1, true); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + let _ = color; + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("PRESSPULL: solid created.").as_ref()); + if let Some(delta) = pending { + self.commit_undo_delta(i, delta); + } + } + } else { + self.command_line.push_error( + crate::t!("PRESSPULL: select a closed profile or planar solid face.") + .as_ref(), + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } + // ── REVOLVE ──────────────────────────────────────────────────── CmdResult::RevolveEntity { handle, axis_start, axis_end, angle_deg, - color, + color: _, } => { if self.reject_locked_edit(i, handle) { self.tabs[i].active_cmd = None; return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::{solid_model, sweep_model}; + use crate::scene::model::sweep_model; let entity_opt = self.tabs[i].scene.document.get_entity(handle).cloned(); if let Some(entity) = entity_opt { - // A line turned about the axis sweeps into a plane, a - // cylinder or a cone, and an arc into a sphere or a - // torus, so a revolved solid keeps exact geometry too. let result = sweep_model::revolved( &entity, [ @@ -3117,31 +3125,19 @@ impl OpenCADStudio { ], [axis_end.x as f64, axis_end.y as f64, axis_end.z as f64], (angle_deg as f64).to_radians(), - ) - .and_then(|body| Some((solid_model::mesh_from_solid(&body, color)?, body))); - if let Some((mesh, solid)) = result { - let history = crate::scene::model::solid_history::revolve_op( - &entity, - axis_start.to_array(), - axis_end.to_array(), - (angle_deg as f64).to_radians(), - ); + ); + if let Some(solid) = result { + let history = crate::scene::model::solid_history::brep_op(&solid); let pending = self.begin_undo(i, "REVOLVE", 1, true); - let mut s3d = empty_solid3d(); - if let acadrust::EntityType::Solid3D(inner) = &mut s3d { - inner.wires = solid_model::edge_wires(&solid); - } - let new_handle = self.tabs[i].scene.add_entity(s3d); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - self.tabs[i].scene.register_solid_model(new_handle, solid); - let _ = mesh; - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line.push_output( + crate::tf!("REVOLVE: solid created ({:.0}°).", angle_deg).as_ref(), + ); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line @@ -3160,7 +3156,7 @@ impl OpenCADStudio { CmdResult::SweepEntity { profile_handle, path_handle, - color, + color: _, } => { if self.reject_locked_edit(i, profile_handle) || self.reject_locked_edit(i, path_handle) @@ -3169,7 +3165,7 @@ impl OpenCADStudio { return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::sweep_model; + use crate::scene::model::{solid_history, sweep_model}; let profile_ent = self.tabs[i] .scene @@ -3177,39 +3173,21 @@ impl OpenCADStudio { .get_entity(profile_handle) .cloned(); let path_ent = self.tabs[i].scene.document.get_entity(path_handle).cloned(); - let history = profile_ent - .as_ref() - .zip(path_ent.as_ref()) - .map(|(profile, path)| { - crate::scene::model::solid_history::sweep_op(profile, path) - }); let result = profile_ent .zip(path_ent) - .and_then(|(profile, path)| sweep_model::swept(&profile, &path, color)); + .and_then(|(profile, path)| sweep_model::swept(&profile, &path)); - if let Some(mut set) = result { + if let Some(solid) = result { let pending = self.begin_undo(i, "SWEEP", 1, true); - let mut entity = empty_solid3d(); - if let acadrust::EntityType::Solid3D(solid) = &mut entity { - let center = set.metrics.centroid; - solid.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - let new_handle = self.tabs[i].scene.add_entity(entity); - if let Some(history) = history { - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - } - for mesh in &mut set.lods { - mesh.name = format!("{}", new_handle.value()); - } - self.tabs[i].scene.meshes.insert(new_handle, set); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("SWEEP: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("SWEEP: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("SWEEP: could not sweep the profile along the path.").as_ref()); @@ -3221,7 +3199,7 @@ impl OpenCADStudio { } // ── LOFT ────────────────────────────────────────────────────── - CmdResult::LoftEntities { handles, color } => { + CmdResult::LoftEntities { handles, color: _ } => { if let Some(handle) = handles .iter() .find(|handle| self.tabs[i].scene.is_layer_locked(**handle)) @@ -3232,34 +3210,23 @@ impl OpenCADStudio { return Task::none(); } use crate::modules::insert::solid3d_cmds::empty_solid3d; - use crate::scene::model::sweep_model; + use crate::scene::model::{solid_history, sweep_model}; let profiles: Vec = handles .iter() .filter_map(|handle| self.tabs[i].scene.document.get_entity(*handle).cloned()) .collect(); - if let Some(mut set) = sweep_model::lofted(&profiles, color) { - let history = crate::scene::model::solid_history::loft_op(&profiles); + if let Some(solid) = sweep_model::lofted(&profiles) { let pending = self.begin_undo(i, "LOFT", 1, true); - let mut entity = empty_solid3d(); - if let acadrust::EntityType::Solid3D(solid) = &mut entity { - let center = set.metrics.centroid; - solid.point_of_reference = acadrust::types::Vector3::new( - center[0], center[1], center[2], - ); - } - let new_handle = self.tabs[i].scene.add_entity(entity); - self.tabs[i] - .scene - .create_solid_history(new_handle, history); - for mesh in &mut set.lods { - mesh.name = format!("{}", new_handle.value()); - } - self.tabs[i].scene.meshes.insert(new_handle, set); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::t!("LOFT: solid created.").as_ref()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + let history = solid_history::brep_op(&solid); + let created = self.add_solid_model(empty_solid3d(), solid, history); + if !created.is_null() { + self.tabs[i].dirty = true; + self.command_line + .push_output(crate::t!("LOFT: solid created.").as_ref()); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } else { self.command_line.push_error(crate::t!("LOFT: select at least two closed profiles.").as_ref()); @@ -3270,6 +3237,20 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); } + CmdResult::SolidEdgeBlend { + handle, + pick, + value, + fillet, + } => { + let task = self.solid_edge_blend(handle, pick, value, fillet); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + CmdResult::HatcheditApply { handle, name, diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 1f86f876..efc19f56 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1,5 +1,15 @@ use super::*; +fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { + let selected = app.tabs[tab].scene.selected_handles_in_order(); + (selected.len() == 1 + && matches!( + app.tabs[tab].scene.document.get_entity(selected[0]), + Some(acadrust::EntityType::Solid3D(_)) + )) + .then_some(selected[0]) +} + impl OpenCADStudio { pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -950,6 +960,20 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "SOLIDFILLET" => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "FILLET" if selected_solid(self, i).is_some() => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "FILLET" => { use crate::modules::draw::modify::fillet::FilletCommand; let entities: Vec<_> = self.tabs[i] @@ -1083,6 +1107,20 @@ impl OpenCADStudio { } } + "SOLIDCHAMFER" => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + + "CHAMFER" if selected_solid(self, i).is_some() => { + use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; + let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + "CHAMFER" => { use crate::modules::draw::modify::fillet::ChamferCommand; let entities: Vec<_> = self.tabs[i] diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 0b3a06e9..ccf235a1 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -466,31 +466,55 @@ impl OpenCADStudio { } } - // BOX / SPHERE / CYLINDER / CONE / WEDGE / TORUS are handled by the - // Model-tab primitive command above (with the kernel boolean caching). - // ── EXTRUDE ──────────────────────────────────────────────────── - // PRESSPULL on a closed boundary creates a solid by extruding it to a - // height — the same operation as EXTRUDE. THICKEN turns a closed planar - // profile into a solid of the given thickness, which is also an extrude. - "EXTRUDE" | "PRESSPULL" | "THICKEN" => { + "EXTRUDE" | "THICKEN" => { use crate::modules::insert::solid3d_cmds::ExtrudeCommand; // If a single entity is already selected, skip the pick step. let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); if selected.len() == 1 { let handle = selected[0].0; - let mut cmd = ExtrudeCommand::new(color); - cmd.on_entity_pick(handle, glam::DVec3::ZERO); + let mut cmd = ExtrudeCommand::new_named(cmd, color); + if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { + cmd.set_entity_pick_direction( + curve.plane.normal().map(glam::DVec3::from_array), + ); + cmd.on_entity_pick( + handle, + glam::DVec3::from_array(curve.plane.origin), + ); + } self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } else { - let cmd = ExtrudeCommand::new(color); + let cmd = ExtrudeCommand::new_named(cmd, color); self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } + "PRESSPULL" => { + use crate::modules::insert::solid3d_cmds::PresspullCommand; + let selected: Vec<_> = self.tabs[i].scene.selected_entities().into_iter().collect(); + let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); + let mut command = PresspullCommand::new(color); + if selected.len() == 1 + && !matches!(selected[0].1, acadrust::EntityType::Solid3D(_)) + { + if let Some(curve) = crate::entities::curve::entity_curve(selected[0].1) { + command.set_entity_pick_direction( + curve.plane.normal().map(glam::DVec3::from_array), + ); + command.on_entity_pick( + selected[0].0, + glam::DVec3::from_array(curve.plane.origin), + ); + } + } + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + } + // ── REVOLVE ──────────────────────────────────────────────────── "REVOLVE" => { use crate::modules::insert::solid3d_cmds::RevolveCommand; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 085cce33..1a12febf 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -925,14 +925,15 @@ impl OpenCADStudio { } // ── Model commands (3D primitives) ───────────────────────────── - "BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "TORUS" => { + "BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "PYRAMID" | "PYR" + | "TORUS" => { use crate::modules::model::primitive_cmd::PrimitiveCommand; let new_cmd = PrimitiveCommand::new(cmd); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - // ── Design commands (solid booleans) ─────────────────────────── + // ── Solid booleans ───────────────────────────────────────────── "UNION" | "SUBTRACT" | "INTERSECT" => { use crate::modules::model::boolean_cmd::BoolOp; if let Some(op) = BoolOp::from_id(cmd) { @@ -1033,17 +1034,7 @@ impl OpenCADStudio { } } - // PYRAMID [sides] — create an n-sided pyramid mesh. - "PYRAMID" | "PYR" => { - use crate::command::TwoValuePromptCommand; - let c = TwoValuePromptCommand::new( - "PYRAMID", - "PYRAMID base radius:", - "PYRAMID height (add sides by typing a 3rd number):", - ); - self.command_line.push_info(&c.prompt()); - self.tabs[i].active_cmd = Some(Box::new(c)); - } + // PYRAMID [sides] keeps the direct numeric form. cmd if cmd.starts_with("PYRAMID ") || cmd.starts_with("PYR ") => { let nums: Vec = cmd .split_whitespace() diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index a279e5e7..1be644f6 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,15 +1,9 @@ -// 3D solid modelling support on the App: committing Model-tab primitives, -// and the Design-group boolean operations over the scene's cached -// B-reps. -// -// The bodies come from the geometry kernel, so every operation here is on -// analytic surfaces rather than on facets — a turned cylinder is still a -// cylinder afterwards, and saves as one. +// Kernel B-rep solid modelling and exact ACIS persistence. -use acadrust::entities::Solid3D; -use acadrust::objects::SolidHistoryOperation; +use acadrust::{ + entities::Solid3D, objects::SolidHistoryOperation, EntityType, Handle, +}; use cadkernel::brep::Body; -use acadrust::{EntityType, Handle}; use iced::Task; use super::Message; @@ -18,16 +12,25 @@ use crate::scene::model::solid_history; use crate::scene::model::solid_model::{self, Bool}; impl super::OpenCADStudio { - /// Commit a Model-tab solid: add its acadrust entity to the document, then - /// register the B-rep (caches it for booleans + tessellates it into the - /// shaded mesh pipeline). Returns the new entity handle. + /// Add a solid and register its persistent B-rep. pub(super) fn add_solid_model( &mut self, - entity: EntityType, + mut entity: EntityType, solid: Body, history: SolidHistoryOperation, ) -> Handle { let i = self.active_tab; + let EntityType::Solid3D(inner) = &mut entity else { + return Handle::NULL; + }; + inner.wires = solid_model::edge_wires(&solid); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid) + else { + self.command_line + .push_error(crate::t!("The solid could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + inner.set_sat_document(&document); let Some(handle) = self.commit_entity_handle(entity) else { return Handle::NULL; }; @@ -55,35 +58,183 @@ impl super::OpenCADStudio { handles } - /// Run a boolean (`union` / `subtract` / `intersect`) on exactly two - /// selected solids whose B-reps can be restored by the kernel. + fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { + let i = self.active_tab; + let Some(document) = + crate::scene::convert::acis_export::solid_to_sat(&result) + else { + self.command_line + .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); + return false; + }; + let Some(EntityType::Solid3D(mut entity)) = + self.tabs[i].scene.document.get_entity(handle).cloned() + else { + return false; + }; + entity.wires = solid_model::edge_wires(&result); + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + + self.push_undo_snapshot(i, label); + self.tabs[i].scene.delete_solid_history(handle); + if !self.tabs[i] + .scene + .update_entity(EntityType::Solid3D(entity)) + { + self.command_line + .push_error(crate::t!("The solid could not be updated.").as_ref()); + return false; + } + let history = solid_history::brep_op(&result); + self.tabs[i].scene.create_solid_history(handle, history); + self.tabs[i].scene.register_solid_model(handle, result); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + pub(super) fn solid_edge_blend( + &mut self, + handle: Handle, + pick: glam::DVec3, + value: f64, + fillet: bool, + ) -> Task { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Task::none(); + } + if !matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(EntityType::Solid3D(_)) + ) { + self.command_line + .push_error(crate::t!("Select a 3D solid edge.").as_ref()); + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); + let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { + self.command_line + .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); + return Task::none(); + }; + let Some(edge) = solid_model::nearest_edge(&body, pick.to_array()) else { + self.command_line + .push_error(crate::t!("Select a solid edge.").as_ref()); + return Task::none(); + }; + let result = if fillet { + cadkernel::brep::fillet(&body, edge, value) + } else { + cadkernel::brep::chamfer(&body, edge, value) + }; + let Some(result) = result else { + self.command_line.push_error( + crate::t!("Edge operation failed. Use a convex planar solid and a smaller value.") + .as_ref(), + ); + return Task::none(); + }; + let label = if fillet { "SOLIDFILLET" } else { "SOLIDCHAMFER" }; + if self.replace_solid_body(handle, result, label) { + self.command_line + .push_output(crate::tf!("{label}: solid updated.").as_ref()); + } + Task::none() + } + + pub(super) fn solid_face_presspull( + &mut self, + handle: Handle, + pick: glam::DVec3, + distance: f64, + drag: Option, + ) -> Task { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); + let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { + self.command_line + .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); + return Task::none(); + }; + let Some(face) = solid_model::nearest_planar_face(&body, pick.to_array()) else { + self.command_line + .push_error(crate::t!("Select a planar solid face.").as_ref()); + return Task::none(); + }; + let distance = match drag { + Some(point) => { + let Some(normal) = solid_model::planar_face_normal(&body, face) else { + self.command_line + .push_error(crate::t!("Select a planar solid face.").as_ref()); + return Task::none(); + }; + (point - pick).dot(glam::DVec3::from_array(normal)) + } + None => distance, + }; + if !distance.is_finite() || distance.abs() <= 1e-6 { + self.command_line.push_error( + crate::t!("PRESSPULL: drag along the selected face normal.").as_ref(), + ); + return Task::none(); + } + let Some(result) = cadkernel::brep::presspull(&body, face, distance) else { + self.command_line.push_error( + crate::t!("PRESSPULL: the face move would collapse or invalidate the solid.") + .as_ref(), + ); + return Task::none(); + }; + if self.replace_solid_body(handle, result, "PRESSPULL") { + self.command_line + .push_output(crate::t!("PRESSPULL: solid updated.").as_ref()); + } + Task::none() + } + + /// Run a boolean over the selected solids in selection order. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { let i = self.active_tab; let handles = self.selected_solid_handles(); - if handles.len() != 2 { + if handles.len() < 2 { self.command_line - .push_error(crate::t!("Boolean: select exactly two solids created this session.").as_ref()); + .push_error(crate::t!("Boolean: select at least two solids.").as_ref()); return Task::none(); } - let a = self.tabs[i].scene.solid_models[&handles[0]].clone(); - let b = self.tabs[i].scene.solid_models[&handles[1]].clone(); let kind = match op { BoolOp::Union => Bool::Union, BoolOp::Subtract => Bool::Subtract, BoolOp::Intersect => Bool::Intersect, }; - let Some(result) = solid_model::boolean(kind, &a, &b) else { + let mut operands = handles + .iter() + .filter_map(|handle| self.tabs[i].scene.solid_models.get(handle).cloned()); + let mut result = operands.next().expect("at least two restored solids"); + for operand in operands { + let Some(combined) = solid_model::boolean(kind, &result, &operand) else { + self.command_line.push_error( + crate::t!("Boolean failed while combining the selected solids.").as_ref(), + ); + return Task::none(); + }; + result = combined; + } + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { self.command_line - .push_error(crate::t!("Boolean failed — the solids may not overlap.").as_ref()); + .push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref()); return Task::none(); - }; + } self.push_undo_snapshot(i, "BOOLEAN"); - // Remove the two operands (entity + mesh + cached B-rep). self.tabs[i].scene.erase_entities(&handles); - // The result is freshly combined geometry with no ACIS parametrisation, - // so it lives as a Solid3D whose render/boolean data is the injected - // mesh + cached B-rep; its edge wires make it pickable. let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&result); let history = solid_history::brep_op(&result); @@ -100,7 +251,7 @@ impl super::OpenCADStudio { /// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 = /// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The /// kept half is the intersection of the solid with a half-space box, reusing - /// the same boolean path the Design-group tools use. + /// the same boolean path as the modelling tools. pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task { let i = self.active_tab; let handles = self.selected_solid_handles(); @@ -243,63 +394,34 @@ impl super::OpenCADStudio { Task::none() } - /// POLYSOLID — build a wall-like solid from a selected polyline: one box per - /// segment (oriented along it, `width` wide, `height` tall) unioned together, - /// reusing the box primitive + the boolean union path. + /// POLYSOLID — build a wall solid around an exact polyline offset. pub(super) fn solid_polysolid(&mut self, width: f64, height: f64) -> Task { let i = self.active_tab; - let found: Option<(Handle, Vec<[f64; 3]>)> = self.tabs[i] + let found: Option<(Handle, EntityType)> = self.tabs[i] .scene .selected_entities() .iter() .filter(|(h, _)| !self.tabs[i].scene.is_layer_locked(*h)) .find_map(|(h, e)| match e { - EntityType::LwPolyline(_) => crate::entities::curve::entity_curve(e) - .map(|curve| (*h, crate::entities::curve::curve_points(&curve))), + EntityType::LwPolyline(_) => Some((*h, (*e).clone())), _ => None, }); - let Some((handle, pts)) = found else { + let Some((handle, entity)) = found else { self.command_line .push_error(crate::t!("POLYSOLID: select a polyline first.").as_ref()); return Task::none(); }; - let segs: Vec<([f64; 3], [f64; 3])> = pts.windows(2).map(|w| (w[0], w[1])).collect(); - let mut acc: Option = None; - let mut used = 0usize; - for (a, b) in &segs { - let (dx, dy) = (b[0] - a[0], b[1] - a[1]); - let len = (dx * dx + dy * dy).sqrt(); - if len < 1e-9 { - continue; - } - let angle = dy.atan2(dx); - // Local box: X 0..len, Y -w/2..w/2, Z 0..h, then turned to lie - // along the segment and moved onto its start. - let (sin, cos) = angle.sin_cos(); - let Some(bx) = solid_model::box_solid([len / 2.0, 0.0, height / 2.0], len, width, height) - .and_then(|bx| { - solid_model::placed( - &bx, - [cos, sin, 0.0], - [-sin, cos, 0.0], - [0.0, 0.0, 1.0], - [a[0], a[1], a[2]], - ) - }) - else { - continue; - }; - acc = Some(match acc { - None => bx, - Some(prev) => solid_model::boolean(Bool::Union, &prev, &bx).unwrap_or(prev), - }); - used += 1; - } - let Some(result) = acc else { + let Some(result) = crate::scene::model::sweep_model::polysolid(&entity, width, height) + else { self.command_line - .push_error(crate::t!("POLYSOLID: the polyline has no usable segments.").as_ref()); + .push_error(crate::t!("POLYSOLID: the kernel could not offset the polyline.").as_ref()); return Task::none(); }; + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { + self.command_line + .push_error(crate::t!("POLYSOLID: the result could not be encoded as ACIS.").as_ref()); + return Task::none(); + } self.push_undo_snapshot(i, "POLYSOLID"); self.tabs[i].scene.erase_entities(&[handle]); let mut s3d = Solid3D::new(); @@ -310,11 +432,14 @@ impl super::OpenCADStudio { if !h.is_null() { self.tabs[i].scene.select_entity(h, false); } - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "POLYSOLID: built a wall solid from {used} segment(s) (width {width}, height {height})." - ).as_ref()); + if !h.is_null() { + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!("POLYSOLID: created a wall solid (width {width}, height {height}).") + .as_ref(), + ); + } Task::none() } @@ -507,12 +632,12 @@ impl super::OpenCADStudio { self.tabs[i].scene.deselect_all(); if !handle.is_null() { self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output(crate::tf!( + "PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})." + ).as_ref()); } - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line.push_output(crate::tf!( - "PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})." - ).as_ref()); Task::none() } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 5ea4f4e4..8615be0e 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -258,7 +258,7 @@ impl OpenCADStudio { let body = scene.solid_models.get(&h); let sat = needs_acis .then(|| { - body.and_then(crate::scene::convert::acis_export::planar_solid_to_sat) + body.and_then(crate::scene::convert::acis_export::solid_to_sat) }) .flatten(); let wires = needs_wires.then(|| { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 2409b6a2..379eddbf 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3454,6 +3454,38 @@ impl OpenCADStudio { })? }); if let Some(handle) = hit { + let uses_surface_point = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_uses_surface_point()); + let entity_pick_point = if uses_surface_point { + self.tabs[i] + .scene + .solid_click_point_for(p, view_rot2, eye2, bounds, handle) + .unwrap_or(pick_wcs) + } else { + pick_wcs + }; + let entity_pick_direction = if uses_surface_point { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + self.tabs[i] + .scene + .solid_planar_face_normal_at(handle, entity_pick_point) + } else { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(crate::entities::curve::entity_curve) + .and_then(|curve| curve.plane.normal()) + .map(glam::DVec3::from_array) + } + } else { + None + }; // Some commands (e.g. SS_CATCHMENT) need the entity // body before `on_entity_pick` can advance. let inject_first = self.tabs[i] @@ -3484,7 +3516,8 @@ impl OpenCADStudio { let result = self.tabs[i].active_cmd.as_mut().map(|c| { // Shift-swap state for TRIM/EXTEND (#336). c.set_shift(shift); - c.on_entity_pick(handle, pick_wcs) + c.set_entity_pick_direction(entity_pick_direction); + c.on_entity_pick(handle, entity_pick_point) }); // HATCHEDIT: after pick, inject hatch model data into the command. if self.tabs[i] @@ -4011,8 +4044,7 @@ impl OpenCADStudio { ) }) .or_else(|| { - // Block-internal hatch: resolve to the - // parent Insert (AutoCAD behaviour). + // Block-internal hatch: resolve to the parent Insert. scene::pick::hit_test::click_hit_insert_hatch( p, self.tabs[i].scene.insert_hatches_for_click().as_ref(), diff --git a/src/command.rs b/src/command.rs index 7cc6382e..bf5b43d3 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1425,17 +1425,20 @@ pub enum CmdResult { /// Translation vector applied once to every selected point. delta: DVec3, }, - /// Create a Solid3D placeholder entity + associated MeshModel. - /// `mesh_fn` is called with the entity's handle string to build the mesh. - CommitSolid3D { - mesh_fn: Box Option + Send>, - }, - /// Extrude the profile entity `handle` by `height` along Z. + /// Extrude the profile entity `handle` along its plane normal. ExtrudeEntity { handle: Handle, height: f64, color: [f32; 4], }, + /// Pull a closed profile or a planar solid face by a signed distance. + PresspullEntity { + handle: Handle, + pick: DVec3, + distance: f64, + drag: Option, + color: [f32; 4], + }, /// Revolve the profile entity `handle` around the given axis by `angle_deg`. RevolveEntity { handle: Handle, @@ -1455,6 +1458,13 @@ pub enum CmdResult { handles: Vec, color: [f32; 4], }, + /// Round or bevel the straight edge nearest `pick` on a solid. + SolidEdgeBlend { + handle: Handle, + pick: DVec3, + value: f64, + fillet: bool, + }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. @@ -1767,6 +1777,14 @@ pub trait CadCommand: Send { false } + /// Supply the mesh surface hit instead of the working-plane projection. + fn entity_pick_uses_surface_point(&self) -> bool { + false + } + + /// Supply the picked surface or profile direction when available. + fn set_entity_pick_direction(&mut self, _direction: Option) {} + /// Render the entity under the cursor through the normal rollover /// highlight while this command is waiting for an entity pick. fn entity_pick_highlights_hover(&self) -> bool { diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index a2902bd6..3a7fce6f 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -3,6 +3,15 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static str)> { match source { + "Boolean" => Some(("model", "boolean")), + "Create" => Some(("model", "create")), + "Extrude" => Some(("model", "extrude")), + "Loft" => Some(("model", "loft")), + "Polysolid" => Some(("model", "polysolid")), + "Presspull" => Some(("model", "presspull")), + "Pyramid" => Some(("model", "pyramid")), + "Revolve" => Some(("model", "revolve")), + "Sweep" => Some(("model", "sweep")), " {:?}" => Some(("view", "message")), " (none — use: SHORTCUTS SET )" => Some(("common", "none-use-shortcuts-set-key-command")), " Alignment:" => Some(("common", "alignment-indented-label-title-case")), diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 95c9a619..db2898da 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -1,27 +1,19 @@ -// 2D→3D modelling commands — EXTRUDE, REVOLVE, SWEEP, LOFT. -// -// Each picks profile/path entities and emits a `CmdResult` whose handler -// builds a solid and inserts its MeshModel into scene.meshes under a -// minimal placeholder Solid3D entity (empty ACIS — saving will not restore -// the mesh). The standalone primitives (BOX/CYLINDER/CONE/SPHERE/WEDGE/TORUS) -// live in the Model tab (`modules::model::primitive_cmd`). +// Profile-based kernel solid commands stored as exact ACIS data. use acadrust::{entities::Solid3D, EntityType}; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult}; - -// BOX / SPHERE / CYLINDER (and CONE / WEDGE / TORUS) now live in the Model tab -// (`modules::model::primitive_cmd`), which builds them as B-reps cached -// for the Design-group boolean tools. EXTRUDE / REVOLVE / SWEEP / LOFT remain -// here as 2D→3D operations. +use crate::t; // ── EXTRUDE command ──────────────────────────────────────────────────────── pub struct ExtrudeCommand { + command_name: &'static str, step: ExtrudeStep, pub target_handle: acadrust::Handle, + anchor: DVec3, + direction: Option, color: [f32; 4], } @@ -32,10 +24,16 @@ enum ExtrudeStep { } impl ExtrudeCommand { - pub fn new(color: [f32; 4]) -> Self { + pub fn new_named(name: &str, color: [f32; 4]) -> Self { Self { + command_name: match name { + "THICKEN" => "THICKEN", + _ => "EXTRUDE", + }, step: ExtrudeStep::Pick, target_handle: acadrust::Handle::NULL, + anchor: DVec3::ZERO, + direction: None, color, } } @@ -43,7 +41,7 @@ impl ExtrudeCommand { impl CadCommand for ExtrudeCommand { fn name(&self) -> &'static str { - "EXTRUDE" + self.command_name } fn prompt(&self) -> String { match self.step { @@ -55,19 +53,33 @@ impl CadCommand for ExtrudeCommand { fn needs_entity_pick(&self) -> bool { self.step == ExtrudeStep::Pick } - fn on_entity_pick(&mut self, handle: acadrust::Handle, _pt: DVec3) -> CmdResult { + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + fn set_entity_pick_direction(&mut self, direction: Option) { + self.direction = direction.and_then(DVec3::try_normalize); + } + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } self.target_handle = handle; + self.anchor = point; self.step = ExtrudeStep::Height; CmdResult::NeedPoint } fn on_point(&mut self, pt: DVec3) -> CmdResult { if self.step == ExtrudeStep::Height { + let height = self + .direction + .map(|direction| (pt - self.anchor).dot(direction)) + .unwrap_or_else(|| pt.distance(self.anchor)); + if !height.is_finite() || height.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } return CmdResult::ExtrudeEntity { handle: self.target_handle, - height: pt.y.abs().max(1e-4), + height, color: self.color, }; } @@ -81,13 +93,151 @@ impl CadCommand for ExtrudeCommand { .filter(|&h| h.abs() > 1e-6) .map(|h| CmdResult::ExtrudeEntity { handle: self.target_handle, - height: h.abs(), + height: h, color: self.color, }) } fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { + (self.step == ExtrudeStep::Height).then_some((self.anchor, self.direction?)) + } + fn dyn_spec(&self) -> Option { + (self.step == ExtrudeStep::Height).then_some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::Point(self.anchor), + fields: vec![crate::command::DynFieldSpec::new( + crate::command::DynRole::Distance, + )], + guide: crate::command::DynGuide::Radius, + ref_point: None, + }) + } + fn dyn_live_value(&self, cursor: DVec3) -> Option { + Some((cursor - self.anchor).dot(self.direction?)) + } +} + +// ── PRESSPULL command ───────────────────────────────────────────────────── + +pub struct PresspullCommand { + picked: Option<(acadrust::Handle, DVec3)>, + direction: Option, + color: [f32; 4], +} + +impl PresspullCommand { + pub fn new(color: [f32; 4]) -> Self { + Self { + picked: None, + direction: None, + color, + } + } +} + +impl CadCommand for PresspullCommand { + fn name(&self) -> &'static str { + "PRESSPULL" + } + + fn prompt(&self) -> String { + if self.picked.is_none() { + t!("PRESSPULL Select a closed profile or planar solid face:").into_owned() + } else { + t!("PRESSPULL Signed distance:").into_owned() + } + } + + fn needs_entity_pick(&self) -> bool { + self.picked.is_none() + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn set_entity_pick_direction(&mut self, direction: Option) { + self.direction = direction.and_then(|value| value.try_normalize()); + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + self.picked = Some((handle, point)); + CmdResult::NeedPoint + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let Some((handle, pick)) = self.picked else { + return CmdResult::NeedPoint; + }; + let distance = self + .direction + .map(|direction| (point - pick).dot(direction)) + .unwrap_or_else(|| point.distance(pick)); + if !distance.is_finite() || distance.abs() <= 1e-6 { + return CmdResult::NeedPoint; + } + CmdResult::PresspullEntity { + handle, + pick, + distance, + drag: Some(point), + color: self.color, + } + } + + fn wants_text_input(&self) -> bool { + self.picked.is_some() + } + + fn on_text_input(&mut self, text: &str) -> Option { + let (handle, pick) = self.picked?; + crate::entities::common::parse_typed_length(text) + .filter(|distance| distance.abs() > 1e-6) + .map(|distance| CmdResult::PresspullEntity { + handle, + pick, + distance, + drag: None, + color: self.color, + }) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn cursor_axis(&self) -> Option<(DVec3, DVec3)> { + Some((self.picked?.1, self.direction?)) + } + + fn dyn_spec(&self) -> Option { + let (_, anchor) = self.picked?; + Some(crate::command::DynSpec { + anchor: crate::command::DynAnchor::Point(anchor), + fields: vec![crate::command::DynFieldSpec::new( + crate::command::DynRole::Distance, + )], + guide: crate::command::DynGuide::Radius, + ref_point: None, + }) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + let (_, anchor) = self.picked?; + Some((cursor - anchor).dot(self.direction?)) + } } // ── REVOLVE command ──────────────────────────────────────────────────────── @@ -171,7 +321,7 @@ impl CadCommand for RevolveCommand { .ok() .filter(|&a| a.abs() > 1e-3)? }; - Some(self.make_revolve(angle.abs())) + Some(self.make_revolve(angle)) } fn on_enter(&mut self) -> CmdResult { if self.step == RevolveStep::Angle { @@ -323,16 +473,16 @@ impl CadCommand for LoftCommand { } } -// ── Placeholder Solid3D entity construction ──────────────────────────────── +// ── Solid3D entity construction ──────────────────────────────────────────── -/// Create a minimal Solid3D entity with empty ACIS data (placeholder only). pub fn empty_solid3d() -> EntityType { EntityType::Solid3D(Solid3D::new()) } - // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["EXTRUDE"] }); // ExtrudeCommand -inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); // LoftCommand -inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); // RevolveCommand -inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); // SweepCommand +inventory::submit!(crate::command::CommandRegistration { + names: &["EXTRUDE", "THICKEN", "PRESSPULL"] +}); +inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); +inventory::submit!(crate::command::CommandRegistration { names: &["REVOLVE"] }); +inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs index 44a29a08..da1bb10b 100644 --- a/src/modules/model/boolean_cmd.rs +++ b/src/modules/model/boolean_cmd.rs @@ -1,8 +1,8 @@ -// Boolean operations on 3D solids (Design group). The actual CSG runs in +// Boolean operations on 3D solids. The actual CSG runs in // `App::solid_boolean` (src/app/model_ops.rs) using the kernel on the // cached B-reps; this module just names the operations. -/// Which boolean a Design-group tool performs on the two selected solids. +/// Which boolean operation to apply to selected solids. #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum BoolOp { Union, diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs new file mode 100644 index 00000000..65ee2d31 --- /dev/null +++ b/src/modules/model/edge_cmd.rs @@ -0,0 +1,146 @@ +use acadrust::Handle; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, +}; + +#[derive(Clone, Copy)] +pub enum EdgeOperation { + Fillet, + Chamfer, +} + +pub struct SolidEdgeCommand { + operation: EdgeOperation, + target: Option, + picked: Option<(Handle, DVec3)>, + default_value: f64, +} + +impl SolidEdgeCommand { + pub fn new(operation: EdgeOperation, target: Option) -> Self { + Self { + operation, + target, + picked: None, + default_value: 1.0, + } + } + + fn finish(&self, value: f64) -> CmdResult { + let Some((handle, pick)) = self.picked else { + return CmdResult::Cancel; + }; + CmdResult::SolidEdgeBlend { + handle, + pick, + value, + fillet: matches!(self.operation, EdgeOperation::Fillet), + } + } +} + +impl CadCommand for SolidEdgeCommand { + fn name(&self) -> &'static str { + match self.operation { + EdgeOperation::Fillet => "SOLIDFILLET", + EdgeOperation::Chamfer => "SOLIDCHAMFER", + } + } + + fn prompt(&self) -> String { + if self.picked.is_none() { + return crate::t!("Select a solid edge:").into_owned(); + } + match self.operation { + EdgeOperation::Fillet => { + crate::tf!("Specify fillet radius <{:.3}>:", self.default_value).into_owned() + } + EdgeOperation::Chamfer => { + crate::tf!("Specify chamfer distance <{:.3}>:", self.default_value).into_owned() + } + } + } + + fn needs_entity_pick(&self) -> bool { + self.picked.is_none() + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() || self.target.is_some_and(|target| target != handle) { + return CmdResult::NeedPoint; + } + self.picked = Some((handle, point)); + CmdResult::NeedPoint + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let Some((_, pick)) = self.picked else { + return CmdResult::NeedPoint; + }; + let value = point.distance(pick); + if value > 0.0 && value.is_finite() { + self.finish(value) + } else { + CmdResult::NeedPoint + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.picked.is_none() { + return None; + } + let value = text.trim().parse::().ok()?; + (value > 0.0 && value.is_finite()).then(|| self.finish(value)) + } + + fn on_enter(&mut self) -> CmdResult { + if self.picked.is_some() { + self.finish(self.default_value) + } else { + CmdResult::Cancel + } + } + + fn wants_text_input(&self) -> bool { + self.picked.is_some() + } + + fn dyn_commit_as_text(&self) -> bool { + self.picked.is_some() + } + + fn dyn_spec(&self) -> Option { + let (_, pick) = self.picked?; + Some(DynSpec { + anchor: DynAnchor::Point(pick), + fields: vec![DynFieldSpec::new(match self.operation { + EdgeOperation::Fillet => DynRole::Radius, + EdgeOperation::Chamfer => DynRole::Distance, + })], + guide: DynGuide::Radius, + ref_point: None, + }) + } + + fn dyn_live_value(&self, cursor: DVec3) -> Option { + self.picked.map(|(_, pick)| cursor.distance(pick)) + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SOLIDFILLET", "SOLIDCHAMFER"] +}); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 08f6f6f5..5272161a 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -1,11 +1,7 @@ -// Model module — 3D solid modelling. -// -// Model group : create primitive solids (box, cylinder, cone, sphere, …) -// as ACIS Solid3D entities via acadrust's primitive builders. -// Design group : combine solids with the kernel boolean operations -// (union / subtract / intersect). +// Solid creation and kernel modelling tools. pub mod boolean_cmd; +pub mod edge_cmd; pub mod primitive_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; @@ -16,11 +12,20 @@ const BOX_ICON: &[u8] = include_bytes!("../../../assets/icons/box3d.svg"); const CYLINDER_ICON: &[u8] = include_bytes!("../../../assets/icons/cylinder3d.svg"); const CONE_ICON: &[u8] = include_bytes!("../../../assets/icons/cone3d.svg"); const SPHERE_ICON: &[u8] = include_bytes!("../../../assets/icons/sphere3d.svg"); +const PYRAMID_ICON: &[u8] = include_bytes!("../../../assets/icons/pyramid3d.svg"); const WEDGE_ICON: &[u8] = include_bytes!("../../../assets/icons/wedge3d.svg"); const TORUS_ICON: &[u8] = include_bytes!("../../../assets/icons/torus3d.svg"); +const POLYSOLID_ICON: &[u8] = include_bytes!("../../../assets/icons/polysolid.svg"); +const EXTRUDE_ICON: &[u8] = include_bytes!("../../../assets/icons/extrude.svg"); +const REVOLVE_ICON: &[u8] = include_bytes!("../../../assets/icons/revolve.svg"); +const LOFT_ICON: &[u8] = include_bytes!("../../../assets/icons/loft.svg"); +const SWEEP_ICON: &[u8] = include_bytes!("../../../assets/icons/sweep.svg"); +const PRESSPULL_ICON: &[u8] = include_bytes!("../../../assets/icons/presspull.svg"); const UNION_ICON: &[u8] = include_bytes!("../../../assets/icons/union.svg"); const SUBTRACT_ICON: &[u8] = include_bytes!("../../../assets/icons/subtract.svg"); const INTERSECT_ICON: &[u8] = include_bytes!("../../../assets/icons/intersect.svg"); +const FILLET_ICON: &[u8] = include_bytes!("../../../assets/icons/fillet.svg"); +const CHAMFER_ICON: &[u8] = include_bytes!("../../../assets/icons/chamfer.svg"); /// Helper to declare a ribbon tool that fires a named command. fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef { @@ -37,7 +42,7 @@ impl CadModule for ModelModule { "model" } fn title(&self) -> &'static str { - "Model" + "Modelling" } fn ribbon_groups(&self) -> &[RibbonGroup] { @@ -45,31 +50,46 @@ impl CadModule for ModelModule { GROUPS.get_or_init(|| { vec![ RibbonGroup { - title: "Model", + title: "Create", tools: vec![ - RibbonItem::LargeTool(tool("BOX", "Box", BOX_ICON)), - RibbonItem::LargeTool(tool("CYLINDER", "Cylinder", CYLINDER_ICON)), - RibbonItem::LargeTool(tool("CONE", "Cone", CONE_ICON)), - RibbonItem::LargeTool(tool("SPHERE", "Sphere", SPHERE_ICON)), - RibbonItem::Dropdown { - id: "MODEL_MORE", - icon: IconKind::Svg(WEDGE_ICON), + RibbonItem::LargeDropdown { + id: "MODEL_PRIMITIVES", + label: "Box", + icon: IconKind::Svg(BOX_ICON), items: vec![ + ("BOX", "Box", IconKind::Svg(BOX_ICON)), + ("CYLINDER", "Cylinder", IconKind::Svg(CYLINDER_ICON)), + ("CONE", "Cone", IconKind::Svg(CONE_ICON)), + ("SPHERE", "Sphere", IconKind::Svg(SPHERE_ICON)), + ("PYRAMID", "Pyramid", IconKind::Svg(PYRAMID_ICON)), ("WEDGE", "Wedge", IconKind::Svg(WEDGE_ICON)), ("TORUS", "Torus", IconKind::Svg(TORUS_ICON)), + ("POLYSOLID", "Polysolid", IconKind::Svg(POLYSOLID_ICON)), ], - default: "WEDGE", + default: "BOX", }, + RibbonItem::LargeTool(tool("EXTRUDE", "Extrude", EXTRUDE_ICON)), + RibbonItem::LargeTool(tool("REVOLVE", "Revolve", REVOLVE_ICON)), + RibbonItem::LargeTool(tool("LOFT", "Loft", LOFT_ICON)), + RibbonItem::LargeTool(tool("SWEEP", "Sweep", SWEEP_ICON)), + RibbonItem::LargeTool(tool("PRESSPULL", "Presspull", PRESSPULL_ICON)), ], }, RibbonGroup { - title: "Design", + title: "Boolean", tools: vec![ RibbonItem::LargeTool(tool("UNION", "Union", UNION_ICON)), RibbonItem::LargeTool(tool("SUBTRACT", "Subtract", SUBTRACT_ICON)), RibbonItem::LargeTool(tool("INTERSECT", "Intersect", INTERSECT_ICON)), ], }, + RibbonGroup { + title: "Edges", + tools: vec![ + RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)), + RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), + ], + }, ] }) } diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 66941b70..0d43d22b 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -1,22 +1,15 @@ -// 3D primitive creation — BOX / CYLINDER / CONE / SPHERE / WEDGE / PYRAMID / -// TORUS. Each is placed CAD-style with a few clicks (planar footprint first, -// then a height value), then built as a real ACIS `Solid3D` via acadrust's -// `acis::primitives` builders. `Scene::add_entity` tessellates the SAT B-rep -// into the 3D mesh pipeline, so the solid renders, selects, and saves to DXF. -// -// A matching the kernel `Solid` is cached on the scene (see model/mod.rs) when the -// entity is committed, so the Design-group boolean tools can combine it. +// Interactive kernel primitives stored as ACIS Solid3D entities. use acadrust::entities::Solid3D; use acadrust::objects::SolidHistoryOperation; -use acadrust::{primitives, EntityType}; -use glam::DVec3; -use crate::t; +use acadrust::EntityType; use cadkernel::brep::Body; +use glam::DVec3; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::scene::model::solid_model; use crate::scene::model::wire_model::WireModel; +use crate::t; /// Which primitive a `PrimitiveCommand` builds. #[derive(Clone, Copy, PartialEq, Eq)] @@ -26,6 +19,7 @@ pub enum Shape { Cylinder, Cone, Sphere, + Pyramid, Torus, } @@ -37,6 +31,7 @@ impl Shape { "CYLINDER" => Shape::Cylinder, "CONE" => Shape::Cone, "SPHERE" => Shape::Sphere, + "PYRAMID" | "PYR" => Shape::Pyramid, "TORUS" => Shape::Torus, _ => return None, }) @@ -48,6 +43,7 @@ impl Shape { Shape::Cylinder => "CYLINDER", Shape::Cone => "CONE", Shape::Sphere => "SPHERE", + Shape::Pyramid => "PYRAMID", Shape::Torus => "TORUS", } } @@ -129,12 +125,11 @@ impl PrimitiveCommand { .to_cols_array() } - /// Build both the acadrust `Solid3D` (ACIS, for persistence) and the - /// kernel `Body` (rendering + booleans) from the footprint + `height`. - fn build(&self, height: f64) -> Option<(EntityType, Body, SolidHistoryOperation)> { + /// Build the persistent kernel body and its history node. + fn build(&self, height: f64) -> Option<(Body, SolidHistoryOperation)> { use crate::scene::model::solid_history; - let (doc, solid, history) = match self.shape { + let (solid, history) = match self.shape { Shape::Box | Shape::Wedge => { let (a, b) = (self.pts[0], self.pts[1]); let length = (b.x - a.x).abs(); @@ -150,7 +145,6 @@ impl PrimitiveCommand { a.z + height / 2.0, ]; ( - primitives::build_box(center, length, width, height), solid_model::box_solid(center, length, width, height), solid_history::box_op( self.history_transform(origin), @@ -162,7 +156,6 @@ impl PrimitiveCommand { } else { let origin = [a.x.min(b.x), a.y.min(b.y), a.z]; ( - primitives::build_wedge(origin, length, width, height), solid_model::wedge_solid(origin, length, width, height), solid_history::wedge_op( self.history_transform(DVec3::from_array(origin)), @@ -182,7 +175,6 @@ impl PrimitiveCommand { let center = [c.x, c.y, c.z]; if self.shape == Shape::Cylinder { ( - primitives::build_cylinder(center, r, height), solid_model::cylinder_solid(center, r, height), solid_history::cylinder_op( self.history_transform(c), @@ -192,7 +184,6 @@ impl PrimitiveCommand { ) } else { ( - primitives::build_cone(center, r, height), solid_model::cone_solid(center, r, height), solid_history::cone_op( self.history_transform(c), @@ -210,11 +201,21 @@ impl PrimitiveCommand { } let center = [c.x, c.y, c.z]; ( - primitives::build_sphere(center, r), solid_model::sphere_solid(center, r), 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::Torus => { let c = self.pts[0]; let first = (self.pts[1] - c).length(); @@ -228,7 +229,6 @@ impl PrimitiveCommand { let minor = (outer - inner) * 0.5; let center = [c.x, c.y, c.z]; ( - primitives::build_torus(center, major, minor), solid_model::torus_solid(center, major, minor), solid_history::torus_op( self.history_transform(c), @@ -239,14 +239,12 @@ impl PrimitiveCommand { } }; let solid = solid?; - let mut s3d = Solid3D::new(); - s3d.set_sat_document(&doc); - Some((EntityType::Solid3D(s3d), solid, history)) + Some((solid, history)) } fn commit(&self, height: f64) -> CmdResult { match self.build(height) { - Some((entity, solid, history)) => { + Some((solid, history)) => { // Built upright in its own frame, then put on the working // plane — the same move `place_entity` makes for the ACIS // copy, so the two stay on top of each other. @@ -262,11 +260,21 @@ impl PrimitiveCommand { ], ); match placed { - Some(placed) => CmdResult::CommitSolid { - entity: self.plane.place_entity(entity), - solid: Box::new(placed), - history, - }, + Some(placed) => { + let Some(document) = + crate::scene::convert::acis_export::solid_to_sat(&placed) + else { + return CmdResult::Cancel; + }; + let mut entity = Solid3D::new(); + entity.set_sat_document(&document); + entity.wires = solid_model::edge_wires(&placed); + CmdResult::CommitSolid { + entity: EntityType::Solid3D(entity), + solid: Box::new(placed), + history, + } + } None => CmdResult::Cancel, } } @@ -390,7 +398,15 @@ impl CadCommand for PrimitiveCommand { fn footprint_wire(shape: Shape, pts: &[DVec3]) -> WireModel { let mut points: Vec<[f32; 3]> = Vec::new(); - if shape.radial() { + if shape == Shape::Pyramid { + let center = pts[0]; + let radius = (pts[1] - center).length(); + let corners: [DVec3; 4] = std::array::from_fn(|index| { + let angle = index as f64 * std::f64::consts::FRAC_PI_2; + center + DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0) + }); + push_loop(&mut points, &corners); + } else if shape.radial() { let c = pts[0]; let r = (pts[1] - c).length(); circle_points(&mut points, c, r); @@ -446,9 +462,21 @@ fn height_wire(shape: Shape, pts: &[DVec3], height: f64) -> WireModel { push_segment(&mut points, low[i], high[i]); } } - Shape::Cylinder | Shape::Cone => { + Shape::Cylinder | Shape::Cone | Shape::Pyramid => { let center = pts[0]; let radius = (pts[1] - center).length(); + if shape == Shape::Pyramid { + let base: [DVec3; 4] = std::array::from_fn(|index| { + let angle = index as f64 * std::f64::consts::FRAC_PI_2; + center + DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0) + }); + push_loop(&mut points, &base); + let apex = center + DVec3::Z * height; + for corner in base { + push_segment(&mut points, corner, apex); + } + return wire("primitive_height_preview", points); + } push_circle(&mut points, center, radius); if shape == Shape::Cylinder { push_circle(&mut points, center + DVec3::Z * height, radius); @@ -506,7 +534,7 @@ fn circle_points(out: &mut Vec<[f32; 3]>, c: DVec3, r: f64) { // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "TORUS"] + names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "PYRAMID", "PYR", "TORUS"] }); fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { @@ -522,9 +550,9 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: name.into(), points, points_low: Vec::new(), diff --git a/src/scene/convert/acis_export.rs b/src/scene/convert/acis_export.rs index 0f522478..48e3730c 100644 --- a/src/scene/convert/acis_export.rs +++ b/src/scene/convert/acis_export.rs @@ -3,12 +3,21 @@ //! Analytic surfaces remain analytic instead of becoming facets. use cadkernel::acis::append; -use acadrust::entities::acis::SatDocument; +use acadrust::entities::acis::{SabReader, SabWriter, SatDocument}; use cadkernel::brep::Body; /// Returns `None` when the body contains an unsupported record form. -pub fn planar_solid_to_sat(body: &Body) -> Option { +pub fn solid_to_sat(body: &Body) -> Option { let mut document = SatDocument::new(); append(body, &mut document).ok()?; - Some(document) + let document = SatDocument::parse(&document.to_sat_string()).ok()?; + let valid = |candidate: &SatDocument| { + let (restored, loss) = cadkernel::acis::lift(candidate); + loss.is_empty() && restored.len() == 1 && restored[0].validate().is_empty() + }; + if !valid(&document) { + return None; + } + let binary = SabWriter::write(&document); + valid(&SabReader::read(&binary).ok()?).then_some(document) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d887e77e..b22dde38 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -518,10 +518,8 @@ pub(crate) fn wire_gpu_patch_enabled() -> bool { /// Resolve a viewport's paper-to-model scale ratio from its two /// DXF-derived sources. /// -/// `view_height` (model-space view extent) is the canonical source — it -/// is what AutoCAD actually uses to draw, and what we keep in sync on -/// every write. `custom_scale` is consulted only when `view_height` is -/// missing or zero (some third-party exporters omit it). +/// `view_height` is canonical. `custom_scale` is the fallback when it is +/// missing or zero. #[inline] pub fn vp_effective_scale(custom_scale: f64, view_height: f64, vp_height: f64) -> f64 { if view_height.abs() > 1e-9 { @@ -2945,8 +2943,8 @@ impl Scene { } /// Guarantee that a paper layout has its full-screen overall (`id == 1`) - /// sheet viewport. AutoCAD always writes one, and `add_layout` creates it, - /// but this is a safety net for layouts that arrive without it. The sheet + /// sheet viewport. `add_layout` creates it; this is a safety net for layouts + /// that arrive without it. The sheet /// viewport is the authoritative paper-space view and the canvas every /// floating viewport overlays. pub fn ensure_sheet_viewport(&mut self, layout_name: &str) { @@ -3016,9 +3014,8 @@ impl Scene { let mut vp = acadrust::entities::Viewport::new(); vp.id = 1; vp.status = acadrust::entities::ViewportStatusFlags::default_on(); - // Paper-space center is a 2D (x, y) point with z = 0 — the same - // convention MVIEW uses for floating viewports. AutoCAD/TrueView read - // the viewport center as (x, y); putting the paper-height midpoint in z + // Paper-space center is a 2D (x, y) point with z = 0. Putting the + // paper-height midpoint in z // (with y = 0) left the sheet view centered at y = 0, shifting the whole // layout half a page down. See issue #156. vp.center = acadrust::types::Vector3::new( @@ -3242,8 +3239,7 @@ impl Scene { } /// Dashed rectangle marking the printable area — the paper inset by the - /// layout's plot margins. AutoCAD draws this guide on every layout; with the - /// margins now preserved we can reflect it too. `None` in model space, when + /// layout's plot margins. `None` in model space, when /// the layout has no margins, or when the inset would be degenerate. pub(super) fn printable_area_wire(&self) -> Option { if self.current_layout == "Model" { @@ -3311,8 +3307,7 @@ impl Scene { /// The effective plot settings for the current layout: a standalone /// PlotSettings page setup if one exists, otherwise the settings embedded in /// the LAYOUT object (paper size, margins, origin, rotation, scale). Loaded - /// AutoCAD files keep their settings embedded, so without this fallback the - /// plot/PDF path would ignore the file's rotation, origin and scale. + /// The fallback preserves rotation, origin and scale from loaded files. pub fn effective_plot_settings(&self) -> Option { self.plot_settings_for(&self.current_layout) } @@ -7974,6 +7969,44 @@ impl Scene { ) } + pub fn solid_click_point_for( + &self, + cursor: iced::Point, + view_rot: glam::Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + target: Handle, + ) -> Option { + let meshes = self.interaction_meshes_arc(); + pick::hit_test::mesh_click_point( + cursor, + meshes.iter().filter_map(|set| { + (set.entity_handle()? == target).then_some(())?; + let mesh = set.geometry_lods().first()?; + Some(( + target, + mesh, + set.instance_transform, + mesh_interaction_aabb(set)?, + )) + }), + view_rot, + eye, + bounds, + ) + } + + pub fn solid_planar_face_normal_at( + &mut self, + handle: Handle, + pick: glam::DVec3, + ) -> Option { + self.restore_solid_models(&[handle]); + let body = self.solid_models.get(&handle)?; + let face = model::solid_model::nearest_planar_face(body, pick.to_array())?; + model::solid_model::planar_face_normal(body, face).map(glam::DVec3::from_array) + } + pub fn view_center_surface_pivot( &self, bounds: iced::Rectangle, @@ -9374,9 +9407,7 @@ impl Scene { if any { return Some((min, max)); } - // Last-resort: the header's saved EXTMIN/EXTMAX. AutoCAD writes these - // on save so opening a file gives ZOOM EXTENTS a useful answer before - // the wire cache is built. + // Last resort: saved EXTMIN/EXTMAX before the wire cache is built. const SANE_EXTENT: f64 = 1.0e16; let h = &self.document.header; let hmin = h.model_space_extents_min; diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index bd143587..9879bd2d 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,11 +1,9 @@ -use acadrust::entities::{EmbeddedEntity, Solid3D}; +use acadrust::entities::Solid3D; use acadrust::objects::{ - SolidHistoryBox, SolidHistoryBrep, SolidHistoryCylinder, SolidHistoryLoft, + SolidHistoryBox, SolidHistoryBrep, SolidHistoryCylinder, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, - SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, + SolidHistorySphere, SolidHistoryTorus, }; -use acadrust::types::{Vector2, Vector3}; -use acadrust::EntityType; use cadkernel::brep::Body; use crate::command::EntityTransform; @@ -474,21 +472,6 @@ fn base(transform: [f64; 16]) -> SolidHistoryNodeBase { base } -fn embedded(entity: &EntityType) -> Option { - Some(match entity { - EntityType::Point(value) => EmbeddedEntity::Point(value.clone()), - EntityType::Line(value) => EmbeddedEntity::Line(value.clone()), - EntityType::Arc(value) => EmbeddedEntity::Arc(value.clone()), - EntityType::Circle(value) => EmbeddedEntity::Circle(value.clone()), - EntityType::Ellipse(value) => EmbeddedEntity::Ellipse(value.clone()), - EntityType::Spline(value) => EmbeddedEntity::Spline(value.clone()), - EntityType::LwPolyline(value) => EmbeddedEntity::LwPolyline(value.clone()), - EntityType::Ray(value) => EmbeddedEntity::Ray(value.clone()), - EntityType::XLine(value) => EmbeddedEntity::XLine(value.clone()), - _ => return None, - }) -} - pub fn box_op( transform: [f64; 16], length: f64, @@ -593,7 +576,7 @@ pub fn pyramid_op( } pub fn brep_op(body: &Body) -> SolidHistoryOperation { - let acis_data = crate::scene::convert::acis_export::planar_solid_to_sat(body) + let acis_data = crate::scene::convert::acis_export::solid_to_sat(body) .map(|document| { let mut solid = Solid3D::new(); solid.set_sat_document(&document); @@ -607,56 +590,3 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation { ..SolidHistoryBrep::default() }) } - -pub fn extrusion_op(profile: &EntityType, height: f64) -> SolidHistoryOperation { - SolidHistoryOperation::Extrusion(SolidHistorySweep { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - direction: Vector3::new(0.0, 0.0, height), - sweep_entity: embedded(profile), - scale_factor: 1.0, - sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - ..SolidHistorySweep::default() - }) -} - -pub fn sweep_op(profile: &EntityType, path: &EntityType) -> SolidHistoryOperation { - SolidHistoryOperation::Sweep(SolidHistorySweep { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - sweep_entity: embedded(profile), - path_entity: embedded(path), - scale_factor: 1.0, - sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(), - ..SolidHistorySweep::default() - }) -} - -pub fn loft_op(profiles: &[EntityType]) -> SolidHistoryOperation { - SolidHistoryOperation::Loft(SolidHistoryLoft { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - cross_sections: profiles.iter().filter_map(embedded).collect(), - ..SolidHistoryLoft::default() - }) -} - -pub fn revolve_op( - profile: &EntityType, - axis_start: [f64; 3], - axis_end: [f64; 3], - angle: f64, -) -> SolidHistoryOperation { - let direction = glam::DVec3::from_array(axis_end) - glam::DVec3::from_array(axis_start); - SolidHistoryOperation::Revolve(SolidHistoryRevolve { - base: base(glam::DMat4::IDENTITY.to_cols_array()), - operation_major: 1, - axis_point: Vector3::new(axis_start[0], axis_start[1], axis_start[2]), - direction: Vector2::new(direction.x, direction.y), - revolve_angle: angle, - sweep_entity: embedded(profile), - ..SolidHistoryRevolve::default() - }) -} diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 15d2fa11..b229981c 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -1,19 +1,6 @@ -// B-rep construction for the Model tab's primitives, plus tessellation into -// the renderer's `MeshLodSet`. -// -// The bodies come from the geometry kernel, which builds each primitive the -// way ACIS records it: an analytic surface with singular vertices where the -// surface has them, rather than a mesh or a spline that happens to look like -// one. That is what lets a solid built here be written back out as exact -// geometry instead of a facetted approximation — see `acis_bridge`. -// -// Everything is oriented Z-up with the footprint on the z = base plane, to -// match acadrust's `acis::primitives`. -// -// The resulting `Body` is cached per entity handle on the Scene so the -// Design-group boolean tools can run on it. +// Kernel B-rep construction and display tessellation. -use cadkernel::brep::{self, Body}; +use cadkernel::brep::{self, Body, Curve3, EdgeKey, FaceKey, Surface}; use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; @@ -223,6 +210,65 @@ pub fn edge_wires(body: &Body) -> Vec { .collect() } +/// B-rep edge nearest a world-space surface pick. +pub fn nearest_edge(body: &Body, pick: [f64; 3]) -> Option { + let pick = cadkernel::space::Vec3::from(pick); + body.edge_keys() + .filter_map(|key| { + let edge = body.edges.get(key)?; + let curve = body.curves.get(edge.curve)?; + let nearest = if matches!(curve, Curve3::Line(_)) { + let start = cadkernel::space::Vec3::from(curve.point_at(edge.start_parameter)); + let end = cadkernel::space::Vec3::from(curve.point_at(edge.end_parameter)); + let span = end - start; + let length2 = span.dot(span); + let along = if length2 > 0.0 { + (pick - start).dot(span) / length2 + } else { + 0.0 + } + .clamp(0.0, 1.0); + pick.distance(start + span * along) + } else { + (0..=64) + .map(|step| { + let t = edge.start_parameter + + (edge.end_parameter - edge.start_parameter) * step as f64 / 64.0; + pick.distance(cadkernel::space::Vec3::from(curve.point_at(t))) + }) + .fold(f64::INFINITY, f64::min) + }; + Some((key, nearest)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(key, _)| key) +} + +/// Planar face nearest a world-space surface pick. +pub fn nearest_planar_face(body: &Body, pick: [f64; 3]) -> Option { + let face = body + .face_keys() + .filter_map(|key| { + let face = body.faces.get(key)?; + let surface = body.surfaces.get(face.surface)?; + Some((key, surface.distance_to(pick).abs())) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(key, _)| key)?; + let node = body.faces.get(face)?; + matches!(body.surfaces.get(node.surface)?, Surface::Plane(_)).then_some(face) +} + +/// Outward normal of a planar face. +pub fn planar_face_normal(body: &Body, face: FaceKey) -> Option<[f64; 3]> { + let face = body.faces.get(face)?; + let Surface::Plane(plane) = body.surfaces.get(face.surface)? else { + return None; + }; + let normal = cadkernel::space::Vec3::from(plane.normal()?); + Some(if face.forward { normal } else { -normal }.to_array()) +} + // ── Boolean operations ────────────────────────────────────────────────────── /// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so @@ -246,17 +292,12 @@ pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { Bool::Subtract => brep::Operation::Difference, Bool::Intersect => brep::Operation::Intersection, }; - brep::combine(a.clone(), b.clone(), how, TOL).ok() + let tolerance = brep::operation_tolerance(&[a, b]); + brep::combine(a.clone(), b.clone(), how, tolerance).ok() } // ── Tessellation ──────────────────────────────────────────────────────────── -/// Tessellate a `Body` into a single-LOD `MeshLodSet` (world-space, before -/// world_offset is applied by the caller). -pub fn mesh_from_solid(body: &Body, color: [f32; 4]) -> Option { - mesh_from_tessellation(tessellation(body), color) -} - fn mesh_from_tessellation( tessellation: brep::mesh::BodyMesh, color: [f32; 4], diff --git a/src/scene/model/sweep_model.rs b/src/scene/model/sweep_model.rs index f79e048c..124e53fd 100644 --- a/src/scene/model/sweep_model.rs +++ b/src/scene/model/sweep_model.rs @@ -1,20 +1,8 @@ -// EXTRUDE / REVOLVE: turning a drawn profile into a solid. -// -// The profile comes from `entities::curve`, which is where every entity's -// geometry is already defined once, so a circle, an arc-bulged polyline and a -// closed spline all arrive as the same thing: a plane plus a chain of curves -// in that plane's coordinates. The kernel sweeps that chain into analytic -// surfaces — a straight run into a plane, an arc into a cylinder, a profile -// turned about an axis into a cone, a sphere or a torus — so the result can -// be written back out as ACIS rather than as facets. -// -// A spline profile has no analytic side wall, so it is refused rather than -// approximated into a surface nobody asked for. That is the kernel's answer -// and this module passes it on. +// Exact profile sweeps stored as kernel B-reps and ACIS. use cadkernel::brep::{self, Body}; -use cadkernel::geom2d::Curve; -use cadkernel::space::{PlanarCurve, Plane}; +use cadkernel::geom2d::{offset_polyline, Arc, Curve, EllipseArc, Line, Polyline}; +use cadkernel::space::{PlanarCurve, Plane, Vec3}; use acadrust::EntityType; use crate::entities::curve::entity_curve; @@ -40,13 +28,17 @@ pub fn profile_of(entity: &EntityType) -> Option { return None; } let pieces = match &planar.curve { - // A polyline is already a chain. Its own segments carry the bulges, - // so an arc stays an arc rather than becoming a run of chords. Curve::Polyline(_) => planar.curve.segments(), - // Everything else closed on itself is cut into quarters, which is - // both the fewest pieces a chain may have and the fewest that leave - // each one unambiguous about which way round it goes. Curve::Circle(circle) => quarters(circle.centre, circle.radius), + Curve::Arc(arc) => split_arc(*arc), + Curve::Ellipse(arc) => split_ellipse(*arc), + Curve::Nurbs(curve) => (0..4) + .map(|part| { + curve + .trimmed(part as f64 / 4.0, (part + 1) as f64 / 4.0) + .map(Curve::Nurbs) + }) + .collect::>>()?, _ => return None, }; (pieces.len() >= 3).then_some(Profile { @@ -57,7 +49,6 @@ pub fn profile_of(entity: &EntityType) -> Option { /// A circle as four arcs. fn quarters(centre: [f64; 2], radius: f64) -> Vec { - use cadkernel::geom2d::Arc; use std::f64::consts::FRAC_PI_2; (0..4) .map(|quarter| { @@ -72,6 +63,34 @@ fn quarters(centre: [f64; 2], radius: f64) -> Vec { .collect() } +fn split_arc(arc: Arc) -> Vec { + let start = arc.start_angle; + let step = arc.sweep() / 4.0; + (0..4) + .map(|part| { + Curve::Arc(Arc { + start_angle: start + step * part as f64, + end_angle: start + step * (part + 1) as f64, + ..arc + }) + }) + .collect() +} + +fn split_ellipse(arc: EllipseArc) -> Vec { + let start = arc.start_parameter; + let step = arc.sweep() / 4.0; + (0..4) + .map(|part| { + Curve::Ellipse(EllipseArc { + ellipse: arc.ellipse, + start_parameter: start + step * part as f64, + end_parameter: start + step * (part + 1) as f64, + }) + }) + .collect() +} + /// EXTRUDE: drag the profile `height` along its own plane's normal. /// /// `None` for a profile that does not close, encloses nothing, or holds a @@ -86,6 +105,13 @@ pub fn extruded(entity: &EntityType, height: f64) -> Option { ) } +/// Signed distance of a drag along a profile's normal. +pub fn projected_drag(entity: &EntityType, from: glam::DVec3, to: glam::DVec3) -> Option { + let normal = entity_curve(entity)?.plane.normal()?; + let distance = (to - from).dot(glam::DVec3::from_array(normal)); + (distance.is_finite() && distance.abs() > 1e-6).then_some(distance) +} + /// REVOLVE: turn the profile about the axis from `from` to `to` by `angle` /// radians. /// @@ -108,85 +134,191 @@ pub fn revolved( ) } -use crate::scene::model::mesh_model::{MeshLodSet, MeshModel}; - -/// SWEEP through the kernel's tolerance-driven mesh API. -pub fn swept(profile: &EntityType, path: &EntityType, color: [f32; 4]) -> Option { - let max_angle = cadkernel::tessellation::DEFAULT_ANGLE; - let surface = brep::mesh::sweep_surface( - &entity_curve(profile)?, - &entity_curve(path)?, - max_angle, - )?; - mesh_set(surface, color, 1e-9) +/// SWEEP as an exact B-rep along straight and circular path pieces. +pub fn swept(profile: &EntityType, path: &EntityType) -> Option { + let profile = profile_of(profile)?; + let path = entity_curve(path)?; + let pieces = match &path.curve { + Curve::Line(line) => vec![Curve::Line(*line)], + Curve::Arc(arc) => vec![Curve::Arc(*arc)], + Curve::Polyline(_) => path.curve.segments(), + _ => return None, + }; + brep::sweep_along(profile.plane, &profile.pieces, path.plane, &pieces) } -/// LOFT through the kernel's tolerance-driven mesh API. -pub fn lofted(profiles: &[EntityType], color: [f32; 4]) -> Option { - let curves: Vec = profiles.iter().filter_map(entity_curve).collect(); - let max_angle = cadkernel::tessellation::DEFAULT_ANGLE; - mesh_set(brep::mesh::loft_surface(&curves, max_angle)?, color, 1e-9) +/// LOFT as an exact B-rep through compatible closed sections. +pub fn lofted(profiles: &[EntityType]) -> Option { + if let Some(body) = circular_loft(profiles) { + return Some(body); + } + let sections = profiles + .iter() + .map(|entity| { + let profile = profile_of(entity)?; + Some((profile.plane, profile.pieces)) + }) + .collect::>>()?; + brep::loft(§ions) } -fn mesh_set( - surface: brep::mesh::SurfaceMesh, - color: [f32; 4], - precision: f64, -) -> Option { - if surface.mesh.is_empty() { +/// Builds an exact wall solid around a polyline centreline. +pub fn polysolid(entity: &EntityType, width: f64, height: f64) -> Option { + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height.abs() <= 1e-12 { return None; } - let mut verts = Vec::with_capacity(surface.mesh.positions.len()); - let mut verts_low = Vec::with_capacity(surface.mesh.positions.len()); - for point in &surface.mesh.positions { - push_point(&mut verts, &mut verts_low, *point); + let planar = entity_curve(entity)?; + let Curve::Polyline(source) = planar.curve else { + return None; + }; + let (left_pick, right_pick) = offset_picks(&source, width)?; + let mut left = offset_polyline(&source, width * 0.5, left_pick); + let mut right = offset_polyline(&source, width * 0.5, right_pick); + if left.len() != 1 || right.len() != 1 { + return None; } - let silhouette = surface.silhouette_source(precision); - let mut set = MeshLodSet::from_single(MeshModel { - name: String::new(), - verts, - verts_low, - normals: surface - .mesh - .normals - .iter() - .map(|normal| [normal[0] as f32, normal[1] as f32, normal[2] as f32]) - .collect(), - indices: surface - .mesh - .triangles - .iter() - .flatten() - .map(|index| *index as u32) - .collect(), - triangle_material_handles: Vec::new(), - triangle_colors: Vec::new(), - color, - selected: false, - }); - { - let (high, low) = (&mut set.edge_verts, &mut set.edge_verts_low); - for edge in surface.edges { - for segment in edge.windows(2) { - for point in segment { - push_point(high, low, *point); - } - } + let left = left.remove(0); + let right = right.remove(0); + let normal = Vec3::from(planar.plane.normal()?); + let direction = (normal * height).to_array(); + + if source.closed { + if !left.closed || !right.closed { + return None; } + let mut profiles = [left, right] + .into_iter() + .map(|polyline| { + let area = Curve::Polyline(polyline.clone()).enclosed_area().abs(); + (area, Curve::Polyline(polyline).segments()) + }) + .collect::>(); + profiles.sort_by(|first, second| second.0.total_cmp(&first.0)); + if profiles[0].0 <= profiles[1].0 || profiles[1].0 <= 1e-12 { + return None; + } + return brep::extrude_region( + planar.plane, + &[profiles.remove(0).1, profiles.remove(0).1], + direction, + ); } - set.curved_gens - .push(super::mesh_model::CurvedGen { source: silhouette }); - Some(set) + + if left.closed || right.closed { + return None; + } + let left_start = left.vertices.first()?.position; + let left_end = left.vertices.last()?.position; + let right_start = right.vertices.first()?.position; + let right_end = right.vertices.last()?.position; + let mut outline = Curve::Polyline(left).segments(); + outline.push(Curve::Line(Line { + start: left_end, + end: right_end, + })); + let mut back = Curve::Polyline(right).segments(); + back.reverse(); + outline.extend(back); + outline.push(Curve::Line(Line { + start: right_start, + end: left_start, + })); + brep::extrude(planar.plane, &outline, direction) } -fn push_point(high: &mut Vec<[f32; 3]>, low: &mut Vec<[f32; 3]>, point: [f64; 3]) { - let coarse = [point[0] as f32, point[1] as f32, point[2] as f32]; - high.push(coarse); - low.push([ - (point[0] - coarse[0] as f64) as f32, - (point[1] - coarse[1] as f64) as f32, - (point[2] - coarse[2] as f64) as f32, - ]); +fn offset_picks(polyline: &Polyline, width: f64) -> Option<([f64; 2], [f64; 2])> { + let start = polyline.vertices.first()?.position; + let next = polyline.vertices.get(1)?.position; + let along = if let Some(arc) = polyline.segment_arc(0) { + let near = arc.sample(1e-6); + [near[0] - start[0], near[1] - start[1]] + } else { + [next[0] - start[0], next[1] - start[1]] + }; + let length = along[0].hypot(along[1]); + if length <= 1e-12 { + return None; + } + let side = [-along[1] / length * width, along[0] / length * width]; + Some(( + [start[0] + side[0], start[1] + side[1]], + [start[0] - side[0], start[1] - side[1]], + )) +} + +fn circular_loft(profiles: &[EntityType]) -> Option { + if profiles.len() < 2 { + return None; + } + let sections = profiles + .iter() + .map(|entity| { + let planar = entity_curve(entity)?; + let Curve::Circle(circle) = planar.curve else { + return None; + }; + Some(( + Vec3::from(planar.plane.point_at(circle.centre)), + circle.radius, + Vec3::from(planar.plane.normal()?), + Vec3::from(planar.plane.x_axis), + )) + }) + .collect::>>()?; + let first = sections.first()?; + let direction = (sections.last()?.0 - first.0).normalize()?; + let scale = sections + .iter() + .map(|(centre, radius, _, _)| centre.length().max(*radius)) + .fold(1.0_f64, f64::max); + let tolerance = scale * 1e-9; + let mut heights = Vec::with_capacity(sections.len()); + for (centre, radius, normal, _) in §ions { + if !radius.is_finite() + || *radius <= tolerance + || normal.dot(direction).abs() < 1.0 - 1e-9 + { + return None; + } + let offset = *centre - first.0; + if (offset - direction * offset.dot(direction)).length() > tolerance { + return None; + } + heights.push(offset.dot(direction)); + } + if heights + .windows(2) + .any(|pair| pair[1] <= pair[0] + tolerance) + { + return None; + } + let radial_seed = first.3 - direction * first.3.dot(direction); + let radial = radial_seed.normalize()?; + let profile_plane = Plane::from_axes(first.0.to_array(), radial.to_array(), direction.to_array()); + let mut points = Vec::with_capacity(sections.len() + 2); + points.push([0.0, 0.0]); + points.extend( + sections + .iter() + .zip(&heights) + .map(|((_, radius, _, _), height)| [*radius, *height]), + ); + points.push([0.0, *heights.last()?]); + let profile = (0..points.len()) + .map(|index| { + Curve::Line(Line { + start: points[index], + end: points[(index + 1) % points.len()], + }) + }) + .collect::>(); + brep::revolve( + profile_plane, + &profile, + first.0.to_array(), + direction.to_array(), + std::f64::consts::TAU, + ) } #[cfg(test)] diff --git a/src/scene/modify.rs b/src/scene/modify.rs index c215ef12..95892900 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -807,7 +807,7 @@ impl Scene { let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { return false; }; - let Some(document) = crate::scene::convert::acis_export::planar_solid_to_sat(&body) else { + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; self.record_solid_history_before(handle); @@ -833,7 +833,7 @@ impl Scene { let Ok(body) = cadkernel::acis::rebuild_body(&operation) else { return false; }; - let Some(document) = crate::scene::convert::acis_export::planar_solid_to_sat(&body) else { + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { return false; }; let Some(EntityType::Solid3D(entity)) = self.document.get_entity_mut(handle) else { From e904844ed9c2a4da94e57ccf1afa2e8a01e8418a Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 23 Aug 2026 21:58:15 +0200 Subject: [PATCH 15/98] Update opencadstudio.ftl Line 336 --- locales/de-DE/opencadstudio.ftl | 68 ++++++++++++++++----------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 9ee6921e..5272afe4 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -53,7 +53,7 @@ options = .browser-storage = Browserspeicherung .dark = Dunkel .default-application = Standardanwendung - .iced-theme = Eisthema: + .iced-theme = Iced-Motiv: .theme = Motiv command-line = @@ -136,13 +136,13 @@ modal = .not-now = Nicht jetzt .open-dwg-and-dxf-drawings-in-open-cad-studio-b = Öffnen Sie .dwg und .dxf Zeichnungen in Open CAD Studio standardmäßig. .path-path = Pfad: __ocs_arg_path__ - .reload-from-disk = Reload von Disk - .save-as = Sparen Sie sich als... - .saving-now-could-destroy-those-external-change = Speichern Sie jetzt diese externen Änderungen zerstören könnte, laden Sie die Festplattenkopie, speichern Sie Ihre lokale Arbeit an anderer Stelle, oder explizit überschreiben. - .the-file-is-open-or-being-used-by-another-appl = Die Datei ist geöffnet oder wird von einer anderen Anwendung verwendet. Schließen Sie sie dort und versuchen Sie es erneut oder speichern Sie diese Zeichnung unter einem anderen Namen. + .reload-from-disk = Von Festplatte neu laden + .save-as = Speichern unter als... + .saving-now-could-destroy-those-external-change = Diese Speichern kann externen Änderungen zerstören, laden Sie eine Festplattenkopie, speichern Sie Ihre Arbeit an anderer Stelle, oder überschreiben diese explizit. + .the-file-is-open-or-being-used-by-another-appl = Die Datei ist geöffnet oder wird von einer anderen Anwendung verwendet. Schließen Sie die andere Anwendung und versuchen Sie es erneut oder speichern Sie diese Zeichnung unter einem anderen Namen. .the-selected-layer-state-is-no-longer-availabl = Der ausgewählte Ebenenzustand steht nicht mehr zur Verfügung. .this-drawing-contains-count-aec-civil-objects = - Diese Zeichnung enthält __ocs_arg_count__ AEC/Zivilobjekte, die "__ocs_arg_target__" nicht speichern können, so dass sie nicht gespeichert werden. + Diese Zeichnung enthält __ocs_arg_count__ AEC/Civilobjekte, die "__ocs_arg_target__" nicht speichern können, so dass sie nicht gespeichert werden. Um sie zu behalten, speichern Sie in der Quellversion (__ocs_arg_src_version__). .yes-set-as-default = Ja, Standardwert .has = enthält @@ -157,13 +157,13 @@ clipboard = .occupied = Die Systemzwischenablage wird gerade verwendet. Versuchen Sie es erneut. .conversion-failed = Der Text in der Zwischenablage konnte nicht gelesen werden. .copybase-specify-base-point = COPYBASE Basispunkt angeben: - .copy-to-clipboard = Kopie an Clipboard + .copy-to-clipboard = Kopie in die Zwischenablage .cut = Ausschneiden - .cut-to-clipboard = Zugeschnitten auf Clipboard - .pasteclip-pick-insertion-point = PASTECLIP Einsteckpunkt: - .paste-as-block = Paste als Block - .paste-from-clipboard = Paste aus Clipboard - .paste-to-original-coordinates = Paste zu Originalkoordinaten + .cut-to-clipboard = Ausschnitt in die Zwischenablage + .pasteclip-pick-insertion-point = PASTECLIP Einfügepunkt: + .paste-as-block = Einfügen als Block + .paste-from-clipboard = Einfügen aus der Zwischenablage + .paste-to-original-coordinates = Einfügen auf Originalkoordinaten viewport = .split-vertical = Ansichtsfenster vertikal teilen @@ -189,7 +189,7 @@ recovery = .show-log = Wiederherstellungsprotokoll anzeigen .save-new-file-required = Eine reparierte Zeichnung muss als neue Datei gespeichert werden. .prompt-heading = Normales Öffnen ist fehlgeschlagen - .prompt-description = Die Zeichnung wurde nicht verändert. Der Wiederherstellungsmodus kann versuchen, verwendbare Daten zu retten; beschädigte Datensätze können dabei übersprungen werden. Möchten Sie fortfahren? + .prompt-description = Die Zeichnung wurde nicht verändert. Der Wiederherstellungsmodus kann versuchen verwendbare Daten zu retten; beschädigte Datensätze können dabei übersprungen werden. Möchten Sie fortfahren? .attempt = Wiederherstellung versuchen .decline = Abbrechen @@ -256,14 +256,14 @@ common = .value-2nd-arrowhead-dimblk2 = 2. Pfeilspitze (DIMBLK2) .value-3d-wireframe = 3D-Drahtmodell .value-3dalign-aligned-the-solid-to-the-destination-p = 3DALIGN: Ausrichtung des Festkörpers auf die Zielpunkte. - .value-3dalign-each-point-triple-must-be-non-coincide = 3DALIGN: Jeder Punkt dreifach muss nicht zusammenfallend und nicht kolinear sein. - .value-3dalign-select-exactly-one-solid-created-this = 3DALIGN: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. - .value-3dmirror-added-a-mirror-across-the-plane = 3DMIRROR: Ein Spiegel über der Ebene __ocs_fmt_0__ wurde hinzugefügt. - .value-3dmirror-could-not-determine-the-solid-s-exten = 3DMIRROR: konnte die Ausdehnung des Feststoffs nicht bestimmen. - .value-3dmirror-select-exactly-one-solid-created-this = 3DMIRROR: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. - .value-3drotate-could-not-determine-the-solid-s-exten = 3DROTATE: konnte die Ausdehnung des Feststoffs nicht bestimmen. + .value-3dalign-each-point-triple-must-be-non-coincide = 3DALIGN: Jedes Punkte-Triple muss nicht zusammenfallend und nicht kolinear sein. + .value-3dalign-select-exactly-one-solid-created-this = 3DALIGN: Wählen Sie genau einen erstellten Volumenkörper. + .value-3dmirror-added-a-mirror-across-the-plane = 3DMIRROR: Eine Spiegelung über der Ebene __ocs_fmt_0__ wurde hinzugefügt. + .value-3dmirror-could-not-determine-the-solid-s-exten = 3DMIRROR: konnte die Ausdehnung des Volumenkörpers nicht bestimmen. + .value-3dmirror-select-exactly-one-solid-created-this = 3DMIRROR: Wählen Sie genau einen erstellten Volumenkörper. + .value-3drotate-could-not-determine-the-solid-s-exten = 3DROTATE: konnte die Ausdehnung des Volumenkörpers nicht bestimmen. .value-3drotate-rotated-angle-deg-about-the-axis = 3DROTATE: gedreht __ocs_fmt_0__° um die Achse __ocs_fmt_1__. - .value-3drotate-select-exactly-one-solid-created-this = 3DROTATE: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. + .value-3drotate-select-exactly-one-solid-created-this = 3DROTATE: Wählen Sie genau einen erstellten Volumenkörper. .align-applied = ALIGN: angewendet. .above = Über .accept = Akzeptieren @@ -274,26 +274,26 @@ common = .alignment-title-case = Ausrichtung .all = Alle .alternate = Alternativ - .alternate-units = Wechseleinheiten + .alternate-units = Alternative Einheiten .angle = Winkel .angular-decimals-dimadec = Winkeldezimalstellen (DIMADEC) .angular-unit-dimaunit = Winkeleinheit (DIMAUNIT) .angular-zero-supp-dimazin = Winkelnullpunkt supp (DIMAZIN) .annotation = Anmerkung - .annotative = Anmerkung + .annotative = Erläuternd .apparent-intersection = Sichtbarer Schnitt .apply = Beantragen .apply-cell = Anwendungszelle - .apply-margins = Anwendung von Einschusszahlungen + .apply-margins = Ränder anwenden .arc = Bogen .arch = Bogen - .architectural = Architektur + .architectural = Architektonisch .area = Fläche .arrow-size = Pfeilgröße .arrowhead = Pfeilkopf .arrowhead-size = Größe des Pfeilkopfes .as-displayed = Wie dargestellt - .attachment-direction = Anlagerichtung: + .attachment-direction = Richtung der Anlage: .attribute = Attribut .attributes = Attribute .auto = auto @@ -314,19 +314,19 @@ common = .block-rotation = Blockdrehung .blocks = Blöcke .blue = Blau - .boolean-failed-the-solids-may-not-overlap = Boolean versagte - die Feststoffe überlappen sich möglicherweise nicht. - .boolean-select-exactly-two-solids-created-this = Boolean: Wählen Sie genau zwei Feststoffe erstellt diese Sitzung. + .boolean-failed-the-solids-may-not-overlap = Boolean versagte - die Volumenkörper überlappen sich möglicherweise nicht. + .boolean-select-exactly-two-solids-created-this = Boolean: Wählen Sie genau zwei erstellte Volumenkörper. .bottom = Unten .bottom-center-title-case = Unten zentriert .bottom-left-title-case = Unten links .bottom-right-title-case = Unten rechts - .bottom-attachment = Bodenbefestigung: - .bottom-center-sentence-case = untere Mitte + .bottom-attachment = Befestigung unten: + .bottom-center-sentence-case = Unten mittig .bottom-left-sentence-case = Unten links - .bottom-right-sentence-case = Rechts unten rechts - .bottomcenter = untere Mitte + .bottom-right-sentence-case = Unten rechts + .bottomcenter = Untere Mitte .bottomleft = Unten links - .bottomright = Rechts unten rechts + .bottomright = Unten rechts .box = Quader .break = Bruch .brightness = Helligkeit @@ -518,7 +518,7 @@ common = .import = Einfuhr .inches = Zoll .index = Index - .insert = Einsatz + .insert = Einfügen .insert-block = Block einfügen .insertion = Einsetzen .installed = Installiert @@ -1879,7 +1879,7 @@ insert = .attsync-no-block-named-arg = ATTSYNC: kein Block mit dem Namen "__ocs_fmt_0__". .attsync-synchronised-synced-insert-s-of-block = ATTSYNC: synchronisierte __ocs_fmt_0__ Einfügen(e) von "__ocs_fmt_1__" gegen __ocs_fmt_2__ Attributdefinition(en). .adjust = Anpassung - .array-insert = Array-Einsatz + .array-insert = Array einfügen .attach = Anlage .attach-xref = XREF anhängen .attribute-editor = Attribut Editor From 77947362716732b6d18777f941f10411d142a4f4 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Sun, 23 Aug 2026 23:44:03 +0300 Subject: [PATCH 16/98] fix(model): repair solid selection flows --- src/app/command_driver.rs | 17 ++++++- src/app/commands/dim.rs | 21 +++++--- src/app/commands/draw.rs | 48 +++++++++++++++++- src/app/model_ops.rs | 74 +++++++++++++++++++++++++++ src/command.rs | 4 ++ src/modules/model/boolean_cmd.rs | 87 ++++++++++++++++++++++++++++++-- 6 files changed, 237 insertions(+), 14 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 68fbe0fe..97a03e26 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -683,8 +683,12 @@ impl OpenCADStudio { } } let was_active = self.tabs[self.active_tab].active_cmd.is_some(); - let preserve_selection = - matches!(result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..)); + let preserve_selection = matches!( + &result, + CmdResult::Relaunch(..) + | CmdResult::Dispatch(..) + | CmdResult::SolidSubtract { .. } + ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; let preview_hidden = self.tabs[i] @@ -3251,6 +3255,15 @@ impl OpenCADStudio { return task; } + CmdResult::SolidSubtract { bases, cutters } => { + let task = self.solid_subtract(&bases, &cutters); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + CmdResult::HatcheditApply { handle, name, diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index efc19f56..0138c9fc 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1,13 +1,20 @@ use super::*; fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { - let selected = app.tabs[tab].scene.selected_handles_in_order(); - (selected.len() == 1 - && matches!( - app.tabs[tab].scene.document.get_entity(selected[0]), - Some(acadrust::EntityType::Solid3D(_)) - )) - .then_some(selected[0]) + let scene = &app.tabs.get(tab)?.scene; + let selected = scene.selected_handles_in_order(); + let [handle] = selected.as_slice() else { + return None; + }; + + if matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) { + Some(*handle) + } else { + None + } } impl OpenCADStudio { diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 1a12febf..065f2e62 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -934,13 +934,57 @@ impl OpenCADStudio { } // ── Solid booleans ───────────────────────────────────────────── - "UNION" | "SUBTRACT" | "INTERSECT" => { + "UNION" | "INTERSECT" => { use crate::modules::model::boolean_cmd::BoolOp; if let Some(op) = BoolOp::from_id(cmd) { - return Some(self.solid_boolean(op)); + let solid_count = { + let scene = &self.tabs[i].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) + }) + .take(2) + .count() + }; + if solid_count < 2 { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else { + return Some(self.solid_boolean(op)); + } } } + "SUBTRACT" => { + use crate::modules::model::boolean_cmd::SubtractCommand; + let bases = { + let scene = &self.tabs[i].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) + }) + .collect() + }; + self.tabs[i].scene.deselect_all(); + let subtract = SubtractCommand::new(bases); + self.command_line.push_info(&subtract.prompt()); + self.tabs[i].active_cmd = Some(Box::new(subtract)); + } + // INTERFERE — non-destructive intersect: solid from the overlap. "INTERFERE" => { return Some(self.solid_interfere()); diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 1be644f6..2fe32100 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -248,6 +248,80 @@ impl super::OpenCADStudio { Task::none() } + pub(super) fn solid_subtract( + &mut self, + bases: &[Handle], + cutters: &[Handle], + ) -> Task { + let i = self.active_tab; + let valid_solid = |handle: &Handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(EntityType::Solid3D(_)) + ) + }; + let mut base_handles: Vec<_> = bases.iter().copied().filter(valid_solid).collect(); + let mut cutter_handles: Vec<_> = cutters.iter().copied().filter(valid_solid).collect(); + cutter_handles.retain(|handle| !base_handles.contains(handle)); + + let mut operands = base_handles.clone(); + operands.extend(cutter_handles.iter().copied()); + self.tabs[i].scene.restore_solid_models(&operands); + base_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + cutter_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + if base_handles.is_empty() || cutter_handles.is_empty() { + self.command_line.push_error( + crate::t!("SUBTRACT: select at least one base solid and one cutter solid.") + .as_ref(), + ); + return Task::none(); + } + + let mut result = self.tabs[i].scene.solid_models[&base_handles[0]].clone(); + for handle in &base_handles[1..] { + let operand = &self.tabs[i].scene.solid_models[handle]; + let Some(combined) = solid_model::boolean(Bool::Union, &result, operand) else { + self.command_line.push_error( + crate::t!("SUBTRACT failed while combining the base solids.").as_ref(), + ); + return Task::none(); + }; + result = combined; + } + for handle in &cutter_handles { + let operand = &self.tabs[i].scene.solid_models[handle]; + let Some(difference) = solid_model::boolean(Bool::Subtract, &result, operand) else { + self.command_line.push_error( + crate::t!("SUBTRACT failed while removing the selected solids.").as_ref(), + ); + return Task::none(); + }; + result = difference; + } + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { + self.command_line + .push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref()); + return Task::none(); + } + + operands = base_handles; + operands.extend(cutter_handles); + self.push_undo_snapshot(i, "SUBTRACT"); + self.tabs[i].scene.erase_entities(&operands); + let mut entity = Solid3D::new(); + entity.wires = solid_model::edge_wires(&result); + let history = solid_history::brep_op(&result); + let handle = self.add_solid_model(EntityType::Solid3D(entity), result, history); + self.tabs[i].scene.deselect_all(); + if !handle.is_null() { + self.tabs[i].scene.select_entity(handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + Task::none() + } + /// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 = /// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The /// kept half is the intersection of the solid with a half-space box, reusing diff --git a/src/command.rs b/src/command.rs index bf5b43d3..0ed4a77f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1465,6 +1465,10 @@ pub enum CmdResult { value: f64, fillet: bool, }, + SolidSubtract { + bases: Vec, + cutters: Vec, + }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document /// and call `attreq_set_attdefs()` on the command, then loop on text input. diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs index da1bb10b..ff607b81 100644 --- a/src/modules/model/boolean_cmd.rs +++ b/src/modules/model/boolean_cmd.rs @@ -1,6 +1,7 @@ -// Boolean operations on 3D solids. The actual CSG runs in -// `App::solid_boolean` (src/app/model_ops.rs) using the kernel on the -// cached B-reps; this module just names the operations. +use acadrust::{EntityType, Handle}; +use glam::DVec3; + +use crate::command::{CadCommand, CmdResult, SelectionEntity}; /// Which boolean operation to apply to selected solids. #[derive(Clone, Copy, PartialEq, Eq, Debug)] @@ -21,6 +22,86 @@ impl BoolOp { } } +enum SubtractStep { + Bases, + Cutters, +} + +pub struct SubtractCommand { + step: SubtractStep, + bases: Vec, + selected: Vec, +} + +impl SubtractCommand { + pub fn new(bases: Vec) -> Self { + let step = if bases.is_empty() { + SubtractStep::Bases + } else { + SubtractStep::Cutters + }; + Self { + step, + bases, + selected: Vec::new(), + } + } +} + +impl CadCommand for SubtractCommand { + fn name(&self) -> &'static str { + "SUBTRACT" + } + + fn prompt(&self) -> String { + match self.step { + SubtractStep::Bases => crate::t!("SUBTRACT Select base solids, then press Enter:") + .into_owned(), + SubtractStep::Cutters => { + crate::t!("SUBTRACT Select solids to subtract, then press Enter:").into_owned() + } + } + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_enter(&mut self) -> CmdResult { + if self.selected.is_empty() { + return CmdResult::NeedPoint; + } + match self.step { + SubtractStep::Bases => { + self.bases = std::mem::take(&mut self.selected); + self.step = SubtractStep::Cutters; + CmdResult::DeselectAndContinue + } + SubtractStep::Cutters => CmdResult::SolidSubtract { + bases: std::mem::take(&mut self.bases), + cutters: std::mem::take(&mut self.selected), + }, + } + } + + fn is_selection_gathering(&self) -> bool { + true + } + + fn inject_selection_entities(&mut self, entities: Vec) { + self.selected = entities + .into_iter() + .filter_map(|entity| { + matches!(entity.entity, EntityType::Solid3D(_)).then_some(entity.handle) + }) + .collect(); + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["UNION", "SUBTRACT", "INTERSECT"] From 31d071246d93a33164dffedb2291df7619cd8420 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Sun, 23 Aug 2026 23:19:56 +0200 Subject: [PATCH 17/98] Update opencadstudio.ftl Line 831 --- locales/de-DE/opencadstudio.ftl | 274 ++++++++++++++++---------------- 1 file changed, 137 insertions(+), 137 deletions(-) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 5272afe4..da00f9b8 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -94,21 +94,21 @@ modal = .keyboard-shortcuts = Tastenkombinationen .command-aliases = Befehlsaliase .find-replace = Suchen und Ersetzen - .plugin-manager = Plug-in-Manager + .plugin-manager = Plug-in-Verwaltung .update-available = Aktualisierung verfügbar - .layer-manager = Layer-Manager - .layer-state-manager = Layerstatus-Manager + .layer-manager = Layer-Verwaltung + .layer-state-manager = Layerstatus-Verwaltung .edit-layer-state = Layerstatus bearbeiten .plot = Plotten - .layout-manager = Layout-Manager - .scale-manager = Maßstabs-Manager + .layout-manager = Ansichtsverwaltung + .scale-manager = Maßstabsverwaltung .annotation-object-scale = Maßstab des Beschriftungsobjekts .plot-style-editor = Plotstileditor - .text-style-manager = Textstil-Manager - .multiline-style-manager = Mehrfachlinienstil-Manager - .table-style-manager = Tabellenstil-Manager - .multileader-style-manager = Multi-Führungslinienstil-Manager - .dimension-style-manager = Bemaßungsstil-Manager + .text-style-manager = Textstil-Verwaltung + .multiline-style-manager = Mehrfachlinienstil-Verwaltung + .table-style-manager = Tabellenstil-Verwaltung + .multileader-style-manager = Multi-Führungslinienstil-Verwaltung + .dimension-style-manager = Bemaßungsstil-Verwaltung .default-application = Standardanwendung .save-warning = Speicherwarnung .unable-save = Zeichnung kann nicht gespeichert werden @@ -334,42 +334,42 @@ common = .byblock = Block .bylayer = ByLayer .convtosurface-converted-n-solid-s-to-surface-s = CONVTOSURFACE: Umgerechnet __ocs_fmt_0__ Feststoff(e) in Oberfläche(n). - .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: Wählen Sie einen Solid, der diese Sitzung erstellt hat. + .convtosurface-select-a-solid-created-this-sess = CONVTOSURFACE: Wählen Sie einen erstellten Volumenkörper. .coords-mode-label = COORDS = __ocs_fmt_0__ (__ocs_fmt_1__) .copy-specify-destination-count-copies-so-far-a = COPY Ziel angeben [bisher __ocs_arg_count__ Kopien | Reihe | Eingabe=Fertig | Basis __ocs_arg_bx__,__ocs_arg_by__]: .cancel = Abbrechen - .cancel-active-command = Aktives Kommando abbrechen - .cannot-delete-a-style-that-is-current-or-in-us = Kann einen Stil, der aktuell oder in Gebrauch ist, nicht löschen. - .cannot-delete-the-model-layout = Das Layout des Modells kann nicht gelöscht werden. + .cancel-active-command = Aktiven Befehl abbrechen + .cannot-delete-a-style-that-is-current-or-in-us = Der Stil ist aktuell in Verwendung und kann nicht gelöscht werden. + .cannot-delete-the-model-layout = Die Modellansicht kann nicht gelöscht werden. .cannot-delete-the-standard-style = Der Standardstil kann nicht gelöscht werden. - .cannot-rename-the-model-layout = Kann das Layout des Modells nicht umbenennen. - .cannot-rename-the-standard-style = Kann den Standard-Stil nicht umbenennen. + .cannot-rename-the-model-layout = Die Modellansicht kann nicht umbenannt werden. + .cannot-rename-the-standard-style = Der Standard-Stil kann nicht umbenannt werden. .catchment-tagged-successfully = Catchment erfolgreich verschlagwortet. .center = Mitte .center-mark-dimcen = Mittelmarke (DIMCEN) .centered = Zentriert .centimeters = Zentimeter - .chamfer = Scheide + .chamfer = Fase .changelog = Änderungsprotokoll - .changing-a-base-colour-switches-to-custom-iced = Durch Wechseln einer Grundfarbe wird auf Custom umgestellt. Iced erzeugt aus diesen sechs Farben jeden Farbton. + .changing-a-base-colour-switches-to-custom-iced = Durch Wechseln einer Grundfarbe wird auf benutzerdefiniert umgestellt. Iced erzeugt aus diesen sechs Farben jeden Farbton. .circle = Kreis .class-version = Klassenversion .clear = Klar - .clear-all = Alles klar + .clear-all = Alles löschen .clip = Zuschneiden .clipboard = Zwischenablage .clipboard-is-empty = Die Zwischenablage ist leer. .close = Schließen .closed-filled = Geschlossen gefüllt .color = Farbe - .command-cancelled-because-the-active-drawing-s = Befehl abgesagt, weil sich der aktive Zeichenraum geändert hat. - .command-history-navigation = Durch den Befehlsverlauf navigieren - .command-label-title-case = Kommando: + .command-cancelled-because-the-active-drawing-s = Befehl abgebrochen, weil sich der aktive Zeichenraum geändert hat. + .command-history-navigation = Navigation im Befehlsverlauf + .command-label-title-case = Befehl: .conceptual = Konzeptionell - .cone = Zapfen + .cone = Kegel .content = Inhalt .content-type = Inhaltsart: - .continue = Weiterfahren + .continue = Fortsetzen .contrast = Kontrast .coordinates = Koordinaten .copied-path = Kopierter Pfad: __ocs_fmt_0__ @@ -386,7 +386,7 @@ common = .cyan = Cyan .cylinder = Zylinder .dataextraction-rows-rows-fname = DATAEXTRACTION __ocs_fmt_0__ rows → "__ocs_fmt_1__" - .dataextraction-write-failed-e = DATAEXTRACTION: Schreibe fehlgeschlagen: __ocs_fmt_0__ + .dataextraction-write-failed-e = DATAEXTRACTION: Schreiben fehlgeschlagen: __ocs_fmt_0__ .ddedit-entity-type-not-supported = DDEDIT: Objekttyp wird nicht unterstützt. .ddedit-text-updated = DDEDIT: Text aktualisiert. .dimbreak-not-yet-implemented-nothing-changed = DIMBREAK: noch nicht umgesetzt – nichts hat sich geändert. @@ -405,7 +405,7 @@ common = .decimals-dimdec = Dezimalstellen (DIMDEC) .default-title-case = Standard .default-text = Standardtext: - .deg-min-sec = Grad/Min/sec + .deg-min-sec = Grad/Min/Sek .delete = Löschen .delete-selected-entities = Ausgewählte Objekte löschen .description-title-case = Beschreibung @@ -414,23 +414,23 @@ common = .dim-style = Bemaßungsstil .dimension = Bemaßung .dimensions = Bemaßungen - .discard = Rückwurf + .discard = Verwerfen .discussions = Diskussionen - .discussions-load-from-github = Diskussionslast von GitHub. + .discussions-load-from-github = Diskussionen von GitHub laden. .display = Anzeige - .distance = Distanz + .distance = Entfernung .donate = Spenden .done = Fertig .double = Doppelt .draft = Entwurf .draw = Zeichnen .drawing = Zeichnung - .drawing-units = Zeichenwerke - .drop-0-ft-keep-0-in = Unterdrücken Sie Null Fuß, halten Sie Null Zoll + .drawing-units = Zeichnungseinheiten + .drop-0-ft-keep-0-in = Unterdrücken Sie Null Fuß, behalten Sie Null Zoll .dynamic = Dynamik .extrude-could-not-build-profile-select-a-close = EXTRUDE: konnte kein Profil erstellen. Wählen Sie eine geschlossene 2D-Entität (Circle, LwPolyline, etc.). .extrude-entity-not-found = EXTRUDE: Objekt nicht gefunden. - .extrude-solid-created = EXTRUDE: solide erstellt. + .extrude-solid-created = EXTRUDE: Volumenkörper erstellt. .edge = Rand .editing-selected = Bearbeiten: __ocs_arg_selected__ .elements = Elemente: @@ -441,37 +441,37 @@ common = .engineering = Technisch .enter = Eingabetaste .expected-coordinates-x-y-or-a-number-got-text = Erwartete Koordinaten (x,y) oder eine Zahl, erhalten: "__ocs_fmt_0__" - .extension = Verlängerung + .extension = Erweiterung .extension-dimdle = Erweiterung (DIMDLE) .extension-dimexe = Erweiterung (DIMEXE) - .extents = Umfang + .extents = Ausmaße .find-replace-replaced-1-occurrence-of-search = FIND/REPLACE: ersetzt 1 Vorkommen von "__ocs_fmt_0__". .find-replace-replaced-replaced-occurrence-s-of = FIND/REPLACE: ersetzt durch __ocs_fmt_0__ Vorkommen von "__ocs_fmt_1__". .flatshot-created-n-projected-edge-s-at-z-0 = FLATSHOT: erstellt __ocs_fmt_0__ projizierte Kante(n) bei Z=0. - .flatshot-select-a-solid-created-this-session = FLATSHOT: Wählen Sie einen Solid, der diese Sitzung erstellt hat. - .fade = Ausblendung + .flatshot-select-a-solid-created-this-session = FLATSHOT: Wählen Sie einen erstellten Volumenkörper. + .fade = Abblendung .failed-to-create-layout-e = Fehler beim Erstellen des Layouts: __ocs_fmt_0__ - .feet = Füße + .feet = Fuß .fence = Zaun .file-locations-are-unavailable-in-the-web-appl = Dateispeicherorte sind in der Webanwendung nicht verfügbar. .fill-color-sentence-case = Füllfarbe .fill-color-aci-dimtfillclr = Farbe ausfüllen ACI (DIMTFILLCLR) - .fillet = Filet - .finalize-command-repeat-last = Befehl finalisieren / Last Repeat - .finalizing = Finalisierung... - .find-and-replace = Finden und Ersetzen - .first-seg-angle = erster Nähwinkel: - .fit = Anpassen + .fillet = Hohlkehle + .finalize-command-repeat-last = Befehl abschließen / Letzten wiederholen + .finalizing = Fertigstellung... + .find-and-replace = Suchen und Ersetzen + .first-seg-angle = erster Segmentwinkel: + .fit = Einpassen .fit-dimatfit = Einpassen (DIMATFIT) .fit-legacy-dimfit = Einpassen (veraltet: DIMFIT) .fixed-height = Feste Höhe: .fixed-length-dimfxl = Feste Länge (DIMFXL) .fixed-length-ext-lines-dimfxlon = Länge der Ext-Linien (DIMFXLON) - .flat-shaded = Flach beschattet + .flat-shaded = Flach schattiert .flat-shaded-edges = Flache schattiert + Kanten - .flow-direction = Strömungsrichtung: - .font-load-failed-script-e = Schriftladefehler (__ocs_fmt_0__): __ocs_fmt_1__ - .force-text-inside-dimtix = Text drinnen erzwingen (DIMTIX) + .flow-direction = Fließrichtung: + .font-load-failed-script-e = Ladefehler Schrift (__ocs_fmt_0__): __ocs_fmt_1__ + .force-text-inside-dimtix = Text innen erzwingen (DIMTIX) .format = Format .format-dimlunit = Format (DIMLUNIT) .fraction-format-dimfrac = Format der Fraktion (DIMFRAC) @@ -500,7 +500,7 @@ common = .height-dimtxt = Höhe (DIMTXT) .helix = Helix .hidden = Verdeckt - .hidden-line = Versteckte Linie + .hidden-line = Verdeckte Linie .high = hoch .horizontal = Horizontal .horizontal-inside-dimtih = Horizontal innen (DIMTIH) @@ -512,25 +512,25 @@ common = .importobj-imported-file-stem-as-mesh = IMPORTOBJ: importiertes "__ocs_fmt_0__" als mesh. .importobj-target-drawing-was-closed = IMPORTOBJ: Zielzeichnung wurde geschlossen. .importobj-error = IMPORTOBJ: __ocs_fmt_0__ - .interfere-created-an-interference-solid-from-t = INTERFERE: erzeugte einen Interferenzkörper aus der Überlappung. - .interfere-select-exactly-two-solids-created-th = INTERFERE: Wählen Sie genau zwei Festkörper aus, die diese Sitzung erstellt haben. - .interfere-the-selected-solids-do-not-overlap = INTERFERE: die ausgewählten Feststoffe überlappen sich nicht. - .import = Einfuhr + .interfere-created-an-interference-solid-from-t = INTERFERE: erzeugte einen Schnittkörper aus der Überlappung. + .interfere-select-exactly-two-solids-created-th = INTERFERE: Wählen Sie genau zwei erstellte Volumenkörper aus. + .interfere-the-selected-solids-do-not-overlap = INTERFERE: die ausgewählten Volumenkörper überlappen sich nicht. + .import = Import .inches = Zoll .index = Index .insert = Einfügen .insert-block = Block einfügen - .insertion = Einsetzen + .insertion = Einfügung .installed = Installiert .intersection = Schnittpunkt .invert-selection-object-s-selected = Invertierte Auswahl: __ocs_fmt_0__ Objekt(e) ausgewählt. .invisible = Unsichtbar .jis = JIS .join-count-in-object-s-joined-into-count-out = JOIN: __ocs_fmt_0__ Objekt(e), das zu __ocs_fmt_1__ verbunden ist. - .jog-angle-dimjogang = Jogwinkel ° (DIMJOGANG) - .join = Beitritt + .jog-angle-dimjogang = Schrägwinkel ° (DIMJOGANG) + .join = Verbindung .keep-0-ft-drop-0-in = Halten Sie Null Fuß, unterdrücken Sie Null Zoll - .keep-recent-files = Bewahren Sie aktuelle Dateien auf + .keep-recent-files = Erhalten Sie aktuelle Dateien .kilometers = Kilometer .layerstate-deleted-name = LAYERSTATE: gelöscht "__ocs_fmt_0__". .layerstate-restored-name-restored-layer-s = LAYERSTATE: wiederhergestellt "__ocs_fmt_0__" (__ocs_fmt_1__ Layer(s)). @@ -539,8 +539,8 @@ common = .layout-e = LAYOUT: __ocs_fmt_0__ .lengthen-applied = LENGTHEN: angewendet. .lengthen-entity-type-not-supported = LENGTHEN: Objekttyp wird nicht unterstützt. - .loft-could-not-loft-profiles-ensure-sections-h = LOFT: konnte keine Profile hochladen. Stellen Sie sicher, dass Abschnitte die gleiche Kantenzahl haben und kompatibel sind. - .loft-solid-created-from-profiles = LOFT: Solid erstellt aus __ocs_fmt_0__ Profilen. + .loft-could-not-loft-profiles-ensure-sections-h = LOFT: konnte keine Profile hochladen. Stellen Sie sicher, dass Schnitte die gleiche Kantenzahl haben und kompatibel sind. + .loft-solid-created-from-profiles = LOFT: Volumenkörper aus __ocs_fmt_0__ Profilen erstellt. .landing-distance-sentence-case = Anlandestrecke .landing-distance-label-sentence-case = Landestrecke: .landing-gap-sentence-case = Landelücke @@ -549,25 +549,25 @@ common = .language-title-case = Sprache .language-label-title-case = Sprache: .layer = Ebene - .layer-matched-to-layer = Layer mit "__ocs_fmt_0__" abgestimmt. + .layer-matched-to-layer = Ebene mit "__ocs_fmt_0__" abgestimmt. .layer-state-name-already-exists = Der Ebenenzustand "__ocs_fmt_0__" existiert bereits. .layer-state-name-was-not-found = Ebenenzustand "__ocs_fmt_0__" wurde nicht gefunden. - .layer-state-name-cannot-be-empty = Der Name des Layer-Status kann nicht leer sein. - .layers = Layer - .layout = Layout - .layout-name-silindi = Layout „__ocs_fmt_0__“ gelöscht - .layout-new-name-created-use-mview-to-add-a-vie = Layout „__ocs_fmt_0__“ erstellt — mit MVIEW kann ein Ansichtsfenster hinzugefügt werden - .layout-orig-new-name = Layout__ocs_fmt_0__" → "__ocs_fmt_1__" - .layout-name-created = Layout '__ocs_fmt_0__' erstellt. - .layout-name-deleted = Layout '__ocs_fmt_0__' gelöscht. - .layout-manager = Layout-Manager - .layout-name-cannot-be-empty = Der Layoutname kann nicht leer sein. - .layout-renamed-old-name-new-name = Layout umbenannt: '__ocs_fmt_0__' → '__ocs_fmt_1__' + .layer-state-name-cannot-be-empty = Der Name des Ebenen-Status kann nicht leer sein. + .layers = Ebenen + .layout = Ansicht + .layout-name-silindi = Ansicht „__ocs_fmt_0__“ gelöscht + .layout-new-name-created-use-mview-to-add-a-vie = Ansicht „__ocs_fmt_0__“ erstellt — mit MVIEW kann ein Ansichtsfenster hinzugefügt werden + .layout-orig-new-name = Ansicht „__ocs_fmt_0__" → "__ocs_fmt_1__" + .layout-name-created = Ansicht '__ocs_fmt_0__' erstellt. + .layout-name-deleted = Ansicht '__ocs_fmt_0__' gelöscht. + .layout-manager = Ansichtsverwaltung + .layout-name-cannot-be-empty = Der Ansichtsname kann nicht leer sein. + .layout-renamed-old-name-new-name = Ansicht umbenannt: '__ocs_fmt_0__' → '__ocs_fmt_1__' .leader = Führungslinie .leader-format = Führungslinienformat .leader-structure = Struktur der Führungslinie .leaders = Führungslinien - .leading-trailing = Leading und Trailing + .leading-trailing = Führung und Verlauf .left = Links .left-attachment = linke Befestigung: .left-to-right-dimtxtdirection = Links nach rechts (DIMTXTDIRECTION) @@ -581,91 +581,91 @@ common = .linetype-scale = Streckenmaßstab .lineweight-title-case = Linienstärke .list = Liste - .loaded = Beladen - .loading-discussions = Ladegespräche... - .loading-videos = Videos laden... - .load = Belastung ... + .loaded = Geladen + .loading-discussions = Lade Diskussionen... + .loading-videos = Lade Videos... + .load = Laden ... .low = niedrig .measure-entity-type-not-supported-or-distance = MEASURE: Objekttyp wird nicht unterstützt oder Abstand ist zu groß. .measure-count-point-s-placed = MEASURE: __ocs_fmt_0__ Punkt(e) platziert. .mleaderalign-leaders-aligned = MLEADERALIGN Führungslinien ausgerichtet. .mleadercollect-leaders-collected = MLEADERCOLLECT Führungslinien gesammelt. .model-uppercase = MODEL - .ms-is-only-available-in-paper-space-layouts = MS ist nur in Papierraumlayouts verfügbar. + .ms-is-only-available-in-paper-space-layouts = MS ist nur in Papieransicht verfügbar. .mspace = MSPACE .mtext = Mtext - .mview-the-clipping-boundary-has-no-usable-area = MVIEW: Die Clipping-Grenze hat keine nutzbare Fläche. - .magenta = Magen + .mview-the-clipping-boundary-has-no-usable-area = MVIEW: Die Schnittgrenze hat keine nutzbare Fläche. + .magenta = Magenta .manage = Verwalten - .meters = Messgeräte + .meters = Meter .middle = Mitte .middle-center-title-case = Mitte zentriert .middle-left-title-case = Mitte links .middle-right-title-case = Mitte rechts - .middle-center-sentence-case = Mittleres Zentrum + .middle-center-sentence-case = Mitte zentriert .middle-left-sentence-case = Mitte links .middle-right-sentence-case = Mitte rechts .middleleft = Mitte links .middleright = Mitte rechts - .midpoint = Mittelwert + .midpoint = Mittelpunkt .miles = Meilen .millimeters = Millimeter .model-title-case = Modell - .modify = Modifizieren - .move = Bewegung + .modify = Ändern + .move = Bewegen .multiple = Mehrfach .multiplier-dimaltf = Multiplikator (DIMALTF) .name-title-case = Name .name-label-title-case = Name: - .nearest = Nächste + .nearest = Nächstgelegen .new = Neu .no = Nein .no-drawing-open-use-new-or-open-first = Keine Zeichnung geöffnet – zuerst Neu oder Öffnen verwenden. .no-groups-found-for-selected-objects = Keine Gruppen für ausgewählte Objekte gefunden. - .no-plugin-handled-command = Kein Plugin wird bearbeitet: __ocs_fmt_0__ - .no-viewport-found-in-this-layout = In diesem Layout wurde kein Ansichtsfenster gefunden. + .no-plugin-handled-command = Kein Plugin bearbeitet: __ocs_fmt_0__ + .no-viewport-found-in-this-layout = In dieser Ansicht wurde kein Ansichtsfenster gefunden. .node = Knotenpunkt .none-title-case = Keine .normal = Normal .nothing-found-at-that-point = Nichts gefunden an diesem Punkt. .nothing-to-redo = Nichts zu wiederholen. - .nothing-to-undo = Nichts zu rückgängig machen. + .nothing-to-undo = Nichts rückgängig zu machen. .ok = OK .object-title-case = Objekt .object-is-on-locked-layer-layer-unlock-the-layer-to-edit-it = Objekt befindet sich auf gesperrter Ebene "__ocs_fmt_0__" - entsperren Sie die Ebene, um es zu bearbeiten. .object-is-on-locked-layer-layer-unlock-the-layer-to-select-or-edit-it = Objekt befindet sich auf gesperrter Ebene "__ocs_fmt_0__" - entsperren Sie die Ebene, um sie auszuwählen oder zu bearbeiten. .oblique = Schräglage (°): .oblique-angle = Schrägwinkel - .off = Inaktiv - .offset = Offset + .off = Aus + .offset = Versatz .on = Ein .open = Öffnen .open-dwg-dxf-not-yet-implemented = DWG/DXF öffnen: noch nicht implementiert. .open-cancelled = Geöffnet abgesagt: "__ocs_fmt_0__" - .open-failed-e = Offen fehlgeschlagen: __ocs_fmt_0__ - .open-or-create-a-drawing-to-add-a-layout = Öffnen oder erstellen Sie eine Zeichnung, um ein Layout hinzuzufügen. + .open-failed-e = Öffnen fehlgeschlagen: __ocs_fmt_0__ + .open-or-create-a-drawing-to-add-a-layout = Öffnen oder erstellen Sie eine Zeichnung, um ein Ansicht hinzuzufügen. .open-or-create-a-drawing-to-manage-layer-state = Öffnen oder erstellen Sie eine Zeichnung, um Ebenenzustände zu verwalten. - .open-or-create-a-drawing-to-manage-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Layouts zu verwalten. - .open-or-create-a-drawing-to-switch-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Layouts zu wechseln. - .opened-file-location = Geöffneter Dateispeicher: __ocs_fmt_0__ + .open-or-create-a-drawing-to-manage-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Ansichten zu verwalten. + .open-or-create-a-drawing-to-switch-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Ansichten zu wechseln. + .opened-file-location = Geöffneter Dateispeicherort: __ocs_fmt_0__ .opening-name-size-label = Öffnen "__ocs_fmt_0__" (__ocs_fmt_1__)... .options-title-case = Optionen .origin = Ursprung - .ortho = Orthopädie + .ortho = Lotrecht .output = Ausgabe .outside = Außen .outside-limits = Außerhalb der Grenzen. .overall-scale = Globale Skalierung .overwrite = Überschreiben .pedit-applied = PEDIT: angewendet. - .pedit-cannot-convert-this-entity = PEDIT: Dieses Objekt kann nicht konvertiert werden. + .pedit-cannot-convert-this-entity = PEDIT: Dieses Objekt kann nicht umgewandelt werden. .pedit-operation-not-applicable-to-this-entity = PEDIT: Der Vorgang ist auf dieses Objekt nicht anwendbar. .perf = PERF .polysolid-built-a-wall-solid-from-used-segment = POLYSOLID: eine Wand aus __ocs_fmt_0__ Segment(s) gebaut (Breite __ocs_fmt_1__, Höhe __ocs_fmt_2__). .polysolid-select-a-polyline-first = POLYSOLID: Wählen Sie zuerst eine Polylinie aus. .polysolid-the-polyline-has-no-usable-segments = POLYSOLID: Die Polylinie hat keine verwertbaren Segmente. .pspace = PSPACE - .pyramid-could-not-build-all-faces = PYRAMID: konnte nicht alle Gesichter bauen. + .pyramid-could-not-build-all-faces = PYRAMID: konnte nicht alle Seitenflächen bauen. .pyramid-created-a-n-sided-pyramid-radius-radiu = PYRAMID: eine __ocs_fmt_0__-seitige Pyramide (Radius __ocs_fmt_1__, Höhe __ocs_fmt_2__) erstellt. .pyramid-radius-and-height-must-be-positive = PYRAMID: Radius und Höhe müssen positiv sein. .pyramid-tessellation-failed = PYRAMID: Tessellation fehlgeschlagen. @@ -673,13 +673,13 @@ common = .page-setup = Seiteneinrichtung .paper = Papier .parallel = Parallel - .parsing-entities = Objekte werden analysiert… + .parsing-entities = Objekte werden ausgewertet… .paste = Einfügen .perpendicular = Rechtwinklig - .photometric = Photometrische + .photometric = Photometrisch .pick-a-point = Wählen Sie einen Punkt .pick-an-object = Wählen Sie ein Objekt - .pinned = gepinnt + .pinned = angeheftet .place-text-manually-dimupt = Text manuell einfügen (DIMUPT) .plot = Plotten .plot-style = Plotstil @@ -689,7 +689,7 @@ common = .plot-window-x0-2-y0-2-to-x1-2-y1-2 = Plot-Fenster: __ocs_fmt_0__,__ocs_fmt_1__ bis __ocs_fmt_2__,__ocs_fmt_3__ .plot-window-x1-2-y1-2-to-x2-2-y2-2 = Plot-Fenster: __ocs_fmt_0__,__ocs_fmt_1__ bis __ocs_fmt_2__,__ocs_fmt_3__ .plugin-id-failed-to-load-e = Plugin '__ocs_fmt_0__' konnte nicht geladen werden: __ocs_fmt_1__ - .plugin-manager = Plug-in-Manager + .plugin-manager = Plug-in-Verwaltung .point = Punkt .point-cloud = Punktwolken .polyline = Polylinie @@ -713,13 +713,13 @@ common = .quality = Qualität .revolve-could-not-revolve-profile = REVOLVE: konnte das Profil nicht drehen. .revolve-entity-not-found = REVOLVE: Objekt nicht gefunden. - .revolve-solid-created-0 = REVOLVE: solide erzeugt (__ocs_fmt_0__°). + .revolve-solid-created-0 = REVOLVE: Volumenkörper erzeugt (__ocs_fmt_0__°). .radians = Radiant .radius = Radius .ray = Strahl - .read-only-session-read-only-saving-is-disabled = Read-only Session (--read-only): Speichern ist deaktiviert. + .read-only-session-read-only-saving-is-disabled = Schreibgeschütze Sitzung (--read-only): Speichern ist deaktiviert. .realistic = Realistisch - .recent-documents = Jüngste Dokumente + .recent-documents = Letzte Dokumente .recent-files = Zuletzt geöffnete Dateien .recent-file-no-longer-exists = Aktuelle Datei existiert nicht mehr: __ocs_fmt_0__ .red = Rot @@ -736,27 +736,27 @@ common = .requires-single-or-multiple = Erfordert Single oder Multiple. .restore = Wiederherstellung .retry = Versuch - .reverse = Rückwärtsgang + .reverse = Rückwärts .right = Rechts - .right-attachment = Rechten Befestigung: + .right-attachment = Rechte Befestigung: .rotate = Rotieren .rotation = Rotation .round-off-dimaltrnd = Abrunden (DIMALTRND) .round-off-dimrnd = Abrunden (DIMRND) - .section-could-not-build-the-cutting-plane = SECTION: konnte die Schnittebene nicht bauen. - .section-could-not-determine-the-solid-s-extent = SECTION: konnte die Ausdehnung des Feststoffs nicht bestimmen. + .section-could-not-build-the-cutting-plane = SECTION: konnte die Schnittebene nicht erstellen. + .section-could-not-determine-the-solid-s-extent = SECTION: konnte die Ausdehnung des Volumenkörpers nicht bestimmen. .section-created-section-line-s-at-value = SECTION: erstellt __ocs_fmt_0__ Schnittlinie(n) bei __ocs_fmt_1__=__ocs_fmt_2__. - .section-select-exactly-one-solid-created-this = SECTION: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. + .section-select-exactly-one-solid-created-this = SECTION: Wählen Sie genau einen erstellten Volumenkörper. .section-solid-modelling-is-unavailable-in-this = SECTION: Solid Modeling ist in diesem Build nicht verfügbar. - .section-the-plane-does-not-cross-the-solid = SECTION: Die Ebene kreuzt den Feststoff nicht. - .slice-failed-the-plane-may-not-cross-the-solid = SLICE fehlgeschlagen - das Flugzeug darf den Feststoff nicht kreuzen. - .slice-could-not-determine-the-solid-s-extent = SLICE: konnte die Ausdehnung des Feststoffs nicht bestimmen. + .section-the-plane-does-not-cross-the-solid = SECTION: Die Ebene kreuzt den Volumenkörper nicht. + .slice-failed-the-plane-may-not-cross-the-solid = SLICE fehlgeschlagen - die Ebene darf den Volumenkörper nicht kreuzen. + .slice-could-not-determine-the-solid-s-extent = SLICE: konnte die Ausdehnung des Volumenkörper nicht bestimmen. .slice-cut-at-ax-value-kept-the-half = SLICE: geschnitten bei __ocs_fmt_0__=__ocs_fmt_1__, die Hälfte von __ocs_fmt_2__ beibehalten. - .slice-select-exactly-one-solid-created-this-se = SLICE: Wählen Sie genau einen Solid, der diese Sitzung erstellt hat. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Die Ebene kreuzt den Festkörper auf der gehaltenen Seite nicht. + .slice-select-exactly-one-solid-created-this-se = SLICE: Wählen Sie genau einen erstellten Volumenkörper. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Die Ebene kreuzt den Volumenkörper auf der gehaltenen Seite nicht. .splinefit-fit-a-spline-through-n-points = SPLINEFIT: Passen Sie einen Spline durch __ocs_fmt_0__ Punkte. - .splinefit-need-at-least-3-points = SPLINEFIT: mindestens 3 Punkte benötigen. - .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: Wählen Sie eine Polylinie, um eine Spline durchzupassen. + .splinefit-need-at-least-3-points = SPLINEFIT: mindestens 3 Punkte werden benötigt. + .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: Wählen Sie eine Polylinie, um eine Spline einzupassen. .stepout-exported-to = STEPOUT: exportiert nach "__ocs_fmt_0__" .stepout-no-3d-mesh-data-in-this-drawing = STEPOUT: keine 3D-Mesh-Daten in dieser Zeichnung. .stepout-error = STEPOUT: __ocs_fmt_0__ @@ -765,15 +765,15 @@ common = .stlout-error = STLOUT: __ocs_fmt_0__ .stretch-nothing-crosses-the-window = STRETCH: Nichts durchquert das Fenster. .stretch-count-entity-ies-stretched = STRETCH: __ocs_fmt_0__ Objekt(e) gestreckt. - .sweep-could-not-sweep-profile-along-path-use-a = SWEEP: Profil entlang des Pfades nicht kehren konnte, ein geschlossenes 2D-Profil und einen Linien- oder Polylinienpfad verwenden. - .sweep-solid-created = SWEEP: solide erstellt. + .sweep-could-not-sweep-profile-along-path-use-a = SWEEP: Profil entlang des Pfades nicht erkannt, verwende ein geschlossenes 2D-Profil und ein Linien- oder Polylinienpfad. + .sweep-solid-created = SWEEP: Volumenkörper erstellt. .save = Speichern - .save-as = Sparen Sie + .save-as = Speichern als .save-drawing-as = Zeichnung speichern als .save-already-running-for-this-drawing = Diese Zeichnung wird bereits gespeichert. - .save-anyway = Auf jeden Fall sparen + .save-anyway = In jeden Fall speichern .save-error-e = Fehler speichern: __ocs_fmt_0__ - .save-in-source-version = Speichern in der Source Version + .save-in-source-version = Speichern in der Quellversion .save-the-drawing-before-copying-its-file-path = Speichern Sie die Zeichnung, bevor Sie den Dateipfad kopieren. .save-the-drawing-before-opening-its-file-locat = Speichern Sie die Zeichnung, bevor Sie den Dateispeicherort öffnen. .saved-download-but-recent-copy-could-not-be-st = Gespeicherter Download, aber die aktuelle Kopie konnte nicht gespeichert werden: __ocs_fmt_0__ @@ -782,20 +782,20 @@ common = .scene-cleared-standard-linetypes-loaded = Szene geleert; Standardlinientypen geladen. .scientific = Wissenschaftlich .search-layers = Ebenen suchen… - .second-seg-angle = zweiter Nähwinkel: + .second-seg-angle = zweiter Segmentwinkel: .select-all = Alle auswählen .select-similar-added = Wählen Sie Ähnliches: __ocs_fmt_0__ addiert. .select-a-tangent-object = Wählen Sie ein Tangentenobjekt. .select-one-object-first-then-run-objectscale = Wählen Sie zuerst ein Objekt aus und führen Sie dann OBJECTSCALE aus. - .send-feedback = Senden Sie Feedback + .send-feedback = Eine Rückmeldung senden .sent-to-printer-printer = Gesendet an Drucker: __ocs_fmt_0__ .set-current = Aktuell setzen - .shade-plot = Schattenfläche - .shadow-flags = Schattenflaggen + .shade-plot = Schattierter Plot + .shadow-flags = Schattenfahnen .single = Einzel .size = Größe - .snap-override-label-next-pick-only = Snap override: __ocs_fmt_0__ (nur nächster Pick). - .solid-created = Solid geschaffen. + .snap-override-label-next-pick-only = Snap Übersteuerung: __ocs_fmt_0__ (nur nächste Auswahl). + .solid-created = Volumenkörper erstellt. .source-object-not-found = Quellobjekt nicht gefunden. .spacing = Abstand .spacing-dimdli = Abstand (DIMDLI) @@ -816,14 +816,14 @@ common = .support-on-patreon = Unterstützung für Patreon .supporters = Unterstützer .suppress-0-ft-0-in = Unterdrücken Sie Null Fuß und Null Zoll - .suppress-leading = Führende Unterdrückung + .suppress-leading = Leitzeichen unterdrücken .suppress-leading-trailing = Unterdrückung von führenden und nachlaufenden Nullen .suppress-leading-zeros = Unterdrücken Sie führende Nullen .suppress-none = Keine Unterdrückung .suppress-outside-arrows-dimsoxd = Unterdrücken Sie äußere Pfeile (DIMSOXD) - .suppress-trailing = Suppress Trailing - .suppress-trailing-zeros = Unterdrücken Sie nacheilende Nullen - .switched-to-layout-name = Umgeschalten auf Layout '__ocs_fmt_0__'. + .suppress-trailing = Nachlauf unterdrücken + .suppress-trailing-zeros = Unterdrücken Sie nachlaufende Nullen + .switched-to-layout-name = Umgeschalten auf Ansicht '__ocs_fmt_0__'. .textedit-selected-entity-is-not-text = TEXTEDIT: Das ausgewählte Objekt ist kein Text. .texteditmode-set-to-display-val = TEXTEDITMODE auf __ocs_fmt_0__ gesetzt .table = Tabelle @@ -1589,9 +1589,9 @@ layers = .layuniso-all-layers-restored = LAYUNISO: Alle Ebenen wiederhergestellt. .layer-freeze = Ebene frieren .layer-lock = Ebene sperren - .layer-manager = Layer-Manager + .layer-manager = Ebenenverwaltung .layer-off = Ebene ausschalten - .layer-state-manager = Layerstatus-Manager + .layer-state-manager = Ebenenstatus-Verwaltung .layer-unlock = Layer Entsperrung .layer-state-name = Name des Ebenenzustands .layer-states-are-stored-inside-the-drawing-and = Ebenenzustände werden innerhalb der Zeichnung gespeichert und bleiben nach dem Wiederöffnen verfügbar. @@ -2938,7 +2938,7 @@ styles = .dim-line-weight-dimlwd = Dim Line Gewicht (DIMLWD) .dimstyle-updated = DimStyle '__ocs_fmt_0__' aktualisiert. .dimension-line = Bemaßungslinie - .dimension-style-manager = Bemaßungsstil-Manager + .dimension-style-manager = Bemaßungsstil-Verwaltung .double-click-a-scale-to-rename-it-edit-its-pap = Zum Umbenennen auf einen Maßstab doppelklicken. Hier wird das Verhältnis zwischen Papier- und Zeichnungseinheiten bearbeitet. Neu oder Kopieren fügt einen Maßstab hinzu. Änderungen werden erst nach Anwenden gespeichert. .down = nach unten .drawing-background = Zeichnungshintergrund @@ -2994,8 +2994,8 @@ styles = .middleoftext = Mitte des Textes .middleoftopline = Mitte der oberen Linie .minus-tolerance-dimtm = Minustoleranz (DIMTM) - .multileader-style-manager = Multiführungslinienstil-Manager - .multiline-style-manager = Mehrfachlinienstil-Manager + .multileader-style-manager = Multiführungslinienstil-Verwaltung + .multiline-style-manager = Mehrfachlinienstil-Verwaltung .no-linetypes-defined = Keine Linientypen definiert. .not-stacked = nicht gestapelt .overkill-deleted-n-duplicate-object-s = OVERKILL: gelöscht __ocs_fmt_0__ doppeltes Objekt(e). @@ -3055,7 +3055,7 @@ styles = .styles-title-case = Stile .texteditmode-v = TEXTEDITMODE = __ocs_fmt_0__ .texteditmode-set-to-n = TEXTEDITMODE auf __ocs_fmt_0__ gesetzt - .table-style-manager = Tabellenstil-Manager + .table-style-manager = Tabellenstil-Verwaltung .text-style-manager = Textstilverwaltung .text-style-label-sentence-case = Textstil: .topoftopline = Obere Linie From 7215faae44645253c5a6abe19eb31914482fd565 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Sun, 23 Aug 2026 21:02:59 +0100 Subject: [PATCH 18/98] feat(properties): make inherited properties editable, align dropdowns, and switch transparency to EditChoice Enable in-place editing of inherited properties in the Properties panel: - Transparency: Switch to PropValue::EditChoice with ByLayer and ByBlock presets to allow direct typing of any numeric percentage value (0..90%) without combo_box autocomplete interference. - Select-all-on-click: Integrate EditChoice text inputs into build_field_key_map with dedicated widget IDs, preserving click-to-select-all across editable fields. - Material: Provide bounded preset choices (ByLayer, ByBlock, Global, and active custom material) to avoid scanning large drawing dictionaries. - Plot style: Implement mode-aware behavior with interactive Choice in STB mode and ReadOnlyWithTooltip in CTB mode. - Read-only tooltips: Introduce PropValue::ReadOnlyWithTooltip to provide contextual explanations for locked fields while preserving mouse text selection and clipboard copying. - Dropdown alignment & unified borders: Fix EditChoice internal input/caret background clipping so the container's 1px neutral/active border surrounds the entire control identically to Lineweight and text inputs. - Document defaults & aggregation: Synchronize document header defaults when editing with No selection, and aggregate heterogeneous multi-selections (*VARIES*). --- src/app/properties.rs | 201 ++++++++++++++++++++++++++------- src/app/update/command.rs | 72 ++++++++++++ src/io/pdf_export.rs | 28 +++-- src/scene/cache/properties.rs | 35 ++++-- src/scene/model/object.rs | 2 + src/scene/selection.rs | 2 + src/scene/view/dispatch.rs | 54 ++++++++- src/ui/color_select.rs | 39 ++++--- src/ui/properties.rs | 144 +++++++++++++++-------- tests/pdf_export_text_check.rs | 27 +++-- tests/text_font_rendering.rs | 1 + 11 files changed, 461 insertions(+), 144 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index abb9b0bc..a7a5088b 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -273,18 +273,60 @@ impl OpenCADStudio { ), ), }, - read_only(t!("Transparency").as_ref(), "ByLayer".to_string()), + Property { + label: t!("Transparency").into_owned(), + field: "transparency", + value: PropValue::EditChoice { + value: "ByLayer".to_string(), + options: vec!["ByLayer".to_string(), "ByBlock".to_string()], + }, + }, read_only(t!("Thickness").as_ref(), format_length(header.thickness)), ], }, PropSection { title: t!("3D Visualization").into_owned(), - props: vec![read_only(t!("Material").as_ref(), material)], + props: vec![Property { + label: t!("Material").into_owned(), + field: "material", + value: PropValue::Choice { + selected: material.clone(), + options: { + let mut opts = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !opts.contains(&material) && !material.is_empty() { + opts.push(material.clone()); + } + opts + }, + }, + }], }, PropSection { title: t!("Plot style").into_owned(), props: vec![ - read_only(t!("Plot style").as_ref(), plot_style.to_string()), + Property { + label: t!("Plot style").into_owned(), + field: "plot_style", + value: if header.plotstyle_mode { + PropValue::Choice { + selected: plot_style.to_string(), + options: vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Normal".to_string(), + ], + } + } else { + PropValue::ReadOnlyWithTooltip { + value: plot_style.to_string(), + tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), + } + }, + }, read_only(t!("Plot style table").as_ref(), plot_table.clone()), read_only( t!("Plot table attached to").as_ref(), @@ -338,6 +380,23 @@ impl OpenCADStudio { ]; ui::PropertiesPanel { title: t!("No selection").into_owned(), + choice_combos: sections + .iter() + .flat_map(|section| section.props.iter()) + .filter_map(|prop| match &prop.value { + crate::scene::model::object::PropValue::Choice { options, .. } => Some(( + prop.field.to_string(), + iced::widget::combo_box::State::new( + options + .iter() + .cloned() + .map(ui::properties::LocalizedChoice::new) + .collect(), + ), + )), + _ => None, + }) + .collect(), sections, layer_combo: iced::widget::combo_box::State::new(layer_names.clone()), linetype_combo: iced::widget::combo_box::State::new( @@ -381,17 +440,10 @@ impl OpenCADStudio { { let doc = &self.tabs[i].scene.document; let common = entity.common(); - let mat_names: Vec = doc - .objects - .iter() - .filter_map(|(_, o)| match o { - acadrust::objects::ObjectType::Material(m) => Some(m.name.clone()), - _ => None, - }) - .collect(); let selected = match common.material_flags { 0 => "ByLayer".to_string(), 1 => "ByBlock".to_string(), + 2 => "Global".to_string(), _ => common .material_handle .and_then(|mh| { @@ -402,10 +454,16 @@ impl OpenCADStudio { _ => None, }) }) - .unwrap_or_else(|| "ByLayer".to_string()), + .unwrap_or_else(|| "Global".to_string()), }; - let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; - options.extend(mat_names); + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !options.contains(&selected) && !selected.is_empty() { + options.push(selected.clone()); + } for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|p| p.field == "material") @@ -665,32 +723,61 @@ impl OpenCADStudio { if self.tabs[i].scene.document.header.plotstyle_mode { let doc = &self.tabs[i].scene.document; let dict_h = doc.header.acad_plotstylename_dict_handle; + let common = entity.common(); + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Normal".to_string(), + ]; if let Some(dict) = crate::scene::annotative::as_dict(doc, dict_h) { - let common = entity.common(); - let mut options = vec!["ByLayer".to_string(), "ByBlock".to_string()]; - options.extend(dict.entries.iter().map(|(n, _)| n.clone())); - let selected = match common.plotstyle_flags { - 0 => "ByLayer".to_string(), - 1 => "ByBlock".to_string(), - _ => common - .plotstyle_handle - .and_then(|ph| { + for (n, _) in &dict.entries { + if !options.contains(n) { + options.push(n.clone()); + } + } + } + let selected = match common.plotstyle_flags { + 0 => "ByLayer".to_string(), + 1 => "ByBlock".to_string(), + 2 => "Normal".to_string(), + _ => common + .plotstyle_handle + .and_then(|ph| { + crate::scene::annotative::as_dict(doc, dict_h).and_then(|dict| { dict.entries .iter() .find(|(_, h)| *h == ph) .map(|(n, _)| n.clone()) }) - .unwrap_or_else(|| "ByLayer".to_string()), - }; - for section in sections.iter_mut() { - if let Some(row) = - section.props.iter_mut().find(|p| p.field == "plot_style") - { - row.value = crate::scene::model::object::PropValue::Choice { - selected: selected.clone(), - options: options.clone(), - }; - } + }) + .unwrap_or_else(|| "ByLayer".to_string()), + }; + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|p| p.field == "plot_style") + { + row.value = crate::scene::model::object::PropValue::Choice { + selected: selected.clone(), + options: options.clone(), + }; + } + } + } else { + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|p| p.field == "plot_style") + { + let common = entity.common(); + let plot_style_str = match common.plotstyle_flags { + 0 => "ByLayer", + 1 => "ByBlock", + 2 => "Normal", + _ => "ByColor", + }; + row.value = crate::scene::model::object::PropValue::ReadOnlyWithTooltip { + value: plot_style_str.to_string(), + tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), + }; } } } @@ -2017,6 +2104,7 @@ fn make_sections_read_only( { let text = match &property.value { PropValue::ReadOnly(value) + | PropValue::ReadOnlyWithTooltip { value, .. } | PropValue::EditText(value) | PropValue::PlainText(value) | PropValue::LayerChoice(value) @@ -2199,20 +2287,36 @@ fn merge_prop_value( options: other_options, .. }, - ) if options == other_options => PropValue::Choice { - selected: VARIES_LABEL.into(), - options: options.clone(), - }, + ) => { + let mut merged_options = options.clone(); + for opt in other_options { + if !merged_options.contains(opt) { + merged_options.push(opt.clone()); + } + } + PropValue::Choice { + selected: VARIES_LABEL.into(), + options: merged_options, + } + } ( PropValue::EditChoice { options, .. }, PropValue::EditChoice { options: other_options, .. }, - ) if options == other_options => PropValue::EditChoice { - value: VARIES_LABEL.into(), - options: options.clone(), - }, + ) => { + let mut merged_options = options.clone(); + for opt in other_options { + if !merged_options.contains(opt) { + merged_options.push(opt.clone()); + } + } + PropValue::EditChoice { + value: VARIES_LABEL.into(), + options: merged_options, + } + } (PropValue::EditText(_), PropValue::EditText(_)) => { PropValue::EditText(VARIES_LABEL.into()) } @@ -2222,6 +2326,19 @@ fn merge_prop_value( (PropValue::ReadOnly(_), PropValue::ReadOnly(_)) => { PropValue::ReadOnly(VARIES_LABEL.into()) } + (PropValue::ReadOnlyWithTooltip { tooltip, .. }, PropValue::ReadOnlyWithTooltip { .. }) => { + PropValue::ReadOnlyWithTooltip { + value: VARIES_LABEL.into(), + tooltip: tooltip.clone(), + } + } + (PropValue::ReadOnly(_), PropValue::ReadOnlyWithTooltip { tooltip, .. }) + | (PropValue::ReadOnlyWithTooltip { tooltip, .. }, PropValue::ReadOnly(_)) => { + PropValue::ReadOnlyWithTooltip { + value: VARIES_LABEL.into(), + tooltip: tooltip.clone(), + } + } (PropValue::HatchPatternChoice(_), PropValue::HatchPatternChoice(_)) => { PropValue::HatchPatternChoice(VARIES_LABEL.into()) } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 805c8959..83d00758 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2109,6 +2109,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].scene.bump_entities(&changes); } } + } else if field == "transparency" { + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) { + crate::scene::view::dispatch::apply_common_prop(entity, "transparency", &value); + } + } } else if field == "plot_style" { // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's @@ -2139,6 +2148,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { common.plotstyle_flags = 1; common.plotstyle_handle = None; } + "Normal" => { + common.plotstyle_flags = 2; + common.plotstyle_handle = None; + } _ => { if let Some(h) = ph { common.plotstyle_flags = 3; @@ -2179,6 +2192,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { common.material_flags = 1; common.material_handle = None; } + "Global" => { + common.material_flags = 2; + common.material_handle = None; + } _ => { if let Some(h) = mat_handle { common.material_flags = 3; @@ -2233,11 +2250,60 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; + self.tabs[i].properties.edit_choice_open = false; if field == "spline_method" { self.tabs[i].properties.prop_vertex = 0; self.tabs[i].properties.prop_vertex_indicator_active = false; } self.refresh_properties(); + } else { + match field { + "transparency" => { + self.tabs[i].dirty = true; + self.refresh_properties(); + } + "material" => { + let mat_handle: Option = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(h, o)| match o { + acadrust::objects::ObjectType::Material(m) if m.name == value => { + Some(*h) + } + _ => None, + }); + match value.as_str() { + "ByLayer" | "ByBlock" | "Global" => { + self.tabs[i].scene.document.header.current_material_handle = + acadrust::Handle::NULL; + } + _ => { + if let Some(h) = mat_handle { + self.tabs[i].scene.document.header.current_material_handle = + h; + } + } + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + "plot_style" => { + match value.as_str() { + "ByBlock" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 1 + } + "Normal" | "ByColor" => { + self.tabs[i].scene.document.header.current_plotstyle_type = 2 + } + _ => self.tabs[i].scene.document.header.current_plotstyle_type = 0, + } + self.tabs[i].dirty = true; + self.refresh_properties(); + } + _ => {} + } } Task::none() } @@ -2470,6 +2536,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].dirty = true; self.refresh_properties(); } + } else { + let _ = self.tabs[i] + .properties + .edit_buf + .remove(&crate::ui::properties::FieldKey::Geom(field)); + self.refresh_properties(); } Task::none() } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 8200a893..ed1c160b 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -1292,12 +1292,15 @@ mod tests { #[test] fn clip_and_scale_emit_pdf_bytes() { - let w = WireModel::solid( - "test".into(), - vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]], - WireModel::WHITE, - false, - ); + let w = PlotWire { + wire: WireModel::solid( + "test".into(), + vec![[0.0, 0.0, 0.0], [50.0, 50.0, 0.0]], + WireModel::WHITE, + false, + ), + draw_depth: 0.0, + }; let bytes = build_pdf( &[w], &[], @@ -1319,7 +1322,7 @@ mod tests { // Build a WireModel carrying the SDF glyph quads for `text` in the embedded // "txt" stroke font, laid out into the process-wide atlas emit_text reads. - fn text_wire(text: &str, origin: [f64; 3]) -> WireModel { + fn text_wire(text: &str, origin: [f64; 3]) -> PlotWire { use crate::scene::pipeline::text_gpu::push_glyph_vertices; use crate::scene::text::{glyph_quads::layout_glyph_quads, sdf_atlas}; let quads = { @@ -1329,9 +1332,12 @@ mod tests { assert!(!quads.is_empty(), "stroke glyphs laid out for {text:?}"); let mut verts = Vec::new(); push_glyph_vertices(&mut verts, &quads, origin, 1.0, [1.0, 0.0, 0.0, 1.0], 0.0); - WireModel { - text_verts: verts, - ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + PlotWire { + wire: WireModel { + text_verts: verts, + ..WireModel::solid("t".into(), Vec::new(), WireModel::WHITE, false) + }, + draw_depth: 0.0, } } @@ -1341,7 +1347,7 @@ mod tests { fn text_grows_the_pdf_vs_no_text() { let wire = text_wire("HELLO", [20.0, 20.0, 0.0]); let mut blank = wire.clone(); - blank.text_verts.clear(); + blank.wire.text_verts.clear(); let with_text = build_pdf( &[wire], diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index b65880a6..fe199e88 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -10,15 +10,15 @@ pub fn general_section(entity: &EntityType) -> PropSection { } else { common.linetype.clone() }; - // Alpha 0 is the ByLayer default (Transparency::BY_LAYER); show it by name - // and fall back to a rounded percentage only for an explicit value. - let transp_display = if common.transparency.alpha() == 0 { - "ByLayer".to_string() - } else { - format!( + // Alpha 0 is ByLayer, Alpha 1 is ByBlock; show them by name and fall back + // to a rounded percentage for explicit values. + let transp_display = match common.transparency.alpha() { + 0 => "ByLayer".to_string(), + 1 => "ByBlock".to_string(), + alpha => format!( "{}", - (common.transparency.alpha() as f64 / 255.0 * 100.0).round() as u32 - ) + (alpha as f64 / 255.0 * 100.0).round() as u32 + ), }; // Hyperlink is stored in XDATA under the "PE_URL" application. @@ -81,7 +81,10 @@ pub fn general_section(entity: &EntityType) -> PropSection { Property { label: t!("Transparency").into_owned(), field: "transparency", - value: PropValue::EditText(transp_display), + value: PropValue::EditChoice { + value: transp_display, + options: vec!["ByLayer".to_string(), "ByBlock".to_string()], + }, }, Property { label: t!("Hyperlink").into_owned(), @@ -136,14 +139,26 @@ pub fn visualization_section(entity: &EntityType) -> Option { let material = match common.material_flags { 0 => "ByLayer", 1 => "ByBlock", + 2 => "Global", _ => "Custom", }; + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Global".to_string(), + ]; + if !options.iter().any(|o| o == material) && !material.is_empty() { + options.push(material.to_string()); + } Some(PropSection { title: t!("3D Visualization").into_owned(), props: vec![Property { label: t!("Material").into_owned(), field: "material", - value: PropValue::ReadOnly(material.into()), + value: PropValue::Choice { + selected: material.to_string(), + options, + }, }], }) } diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index f688072c..613a82f3 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -8,6 +8,8 @@ use glam::DVec3; pub enum PropValue { /// Read-only display text. ReadOnly(String), + /// Read-only display text with a tooltip explaining why it cannot be edited. + ReadOnlyWithTooltip { value: String, tooltip: String }, /// Editable numeric field. EditText(String), /// Editable text that must not be expression-evaluated. diff --git a/src/scene/selection.rs b/src/scene/selection.rs index b58d85e2..af107d9c 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -463,6 +463,7 @@ impl Scene { match prop.value { PropValue::PlainText(_) => QSelectValueEditor::Text, PropValue::ReadOnly(ref value) + | PropValue::ReadOnlyWithTooltip { ref value, .. } | PropValue::EditText(ref value) => { let field = prop.field.to_ascii_lowercase(); let textual = [ @@ -660,6 +661,7 @@ impl Scene { .find(|p| p.field == field)?; Some(match prop.value { PropValue::ReadOnly(s) + | PropValue::ReadOnlyWithTooltip { value: s, .. } | PropValue::EditText(s) | PropValue::PlainText(s) => s, PropValue::LayerChoice(s) => s, diff --git a/src/scene/view/dispatch.rs b/src/scene/view/dispatch.rs index dfeaccbf..34c44132 100644 --- a/src/scene/view/dispatch.rs +++ b/src/scene/view/dispatch.rs @@ -135,11 +135,55 @@ pub fn apply_common_prop(entity: &mut EntityType, field: &str, value: &str) { } } "transparency" => { - if let Ok(pct) = value.trim().parse::() { - let alpha = (pct.clamp(0.0, 100.0) / 100.0 * 255.0).round() as u8; - entity - .as_entity_mut() - .set_transparency(Transparency::new(alpha)); + let s = value.trim(); + if s.eq_ignore_ascii_case("ByLayer") { + entity.as_entity_mut().set_transparency(Transparency::new(0)); + } else if s.eq_ignore_ascii_case("ByBlock") { + entity.as_entity_mut().set_transparency(Transparency::new(1)); + } else { + let clean = s.trim_end_matches('%').trim(); + if let Ok(pct) = clean.parse::() { + let alpha = (pct.clamp(0.0, 90.0) / 100.0 * 255.0).round() as u8; + entity + .as_entity_mut() + .set_transparency(Transparency::new(alpha)); + } + } + } + "material" => { + let common = entity.common_mut(); + match value.trim() { + "ByLayer" => { + common.material_flags = 0; + common.material_handle = None; + } + "ByBlock" => { + common.material_flags = 1; + common.material_handle = None; + } + "Global" => { + common.material_flags = 2; + common.material_handle = None; + } + _ => {} + } + } + "plot_style" => { + let common = entity.common_mut(); + match value.trim() { + "ByLayer" => { + common.plotstyle_flags = 0; + common.plotstyle_handle = None; + } + "ByBlock" => { + common.plotstyle_flags = 1; + common.plotstyle_handle = None; + } + "Normal" => { + common.plotstyle_flags = 2; + common.plotstyle_handle = None; + } + _ => {} } } "thickness" => { diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index ba4d4c2a..233ec87e 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -132,6 +132,7 @@ pub fn color_selector<'a>( row![ swatch(cur_bg), text(cur_name).size(11), + iced::widget::Space::new().width(Length::Fill), crate::ui::icons::themed_arrow_toggle(open, 9.0), ] .spacing(5) @@ -149,22 +150,22 @@ pub fn color_selector<'a>( .style(|theme: &Theme| { let palette = theme.palette(); container::Style { - background: Some(Background::Color(palette.background.weak.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.weak.color)), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() } }) - .padding(5) - .width(220); + .padding(2); // `DropDown` keeps the popup outside the surrounding form layout and // handles viewport placement, Escape, and outside-click dismissal. + // By omitting `.width(...)`, DropDown defaults to the exact pixel width + // of the underlay (`head`), matching the value column width and aligning flush. iced_aw::DropDown::new(head, popup, true) - .width(220) .alignment(iced_aw::drop_down::Alignment::Bottom) .offset(2.0) .on_dismiss(on_dismiss) @@ -187,8 +188,8 @@ fn list_row_style(theme: &Theme, status: button::Status) -> button::Style { } /// The colour list shown inside a picker popup: named ACI colours (with -/// swatches) plus a "More…" entry that opens the full palette window. Shared by -/// `color_selector` and the ribbon's colour overlay. +/// swatches) plus a "Select Color..." entry that opens the full palette window. +/// Shared by `color_selector` and the ribbon's colour overlay. pub fn color_list<'a>( extras: ColorExtras, on_select: impl Fn(AcadColor) -> Message + 'a, @@ -219,7 +220,7 @@ pub fn color_list<'a>( list = list.push(named_row(AcadColor::Index(i))); } list = list.push( - button(text(t!("More…")).size(11)) + button(text(t!("Select Color...")).size(11)) .on_press(on_more) .style(list_row_style) .padding([2, 4]) @@ -232,17 +233,19 @@ pub fn color_list<'a>( pub fn drop_down_below<'a>( base: Element<'a, Message>, popup: Element<'a, Message>, - popup_width: Length, + popup_width: Option, popup_height: Length, on_dismiss: Message, ) -> Element<'a, Message> { - iced_aw::DropDown::new(base, popup, true) - .width(popup_width) + let mut dd = iced_aw::DropDown::new(base, popup, true) .height(popup_height) .alignment(iced_aw::drop_down::Alignment::Bottom) .offset(2.0) - .on_dismiss(on_dismiss) - .into() + .on_dismiss(on_dismiss); + if let Some(w) = popup_width { + dd = dd.width(w); + } + dd.into() } /// Full CAD indexed-colour page used by the standalone Select Color dialog. diff --git a/src/ui/properties.rs b/src/ui/properties.rs index e14838f3..1a036f4c 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -308,9 +308,8 @@ pub fn active_key_focused( /// Precomputes the focused-id → [`FieldKey`] map for every editable value row /// in the given sections. Building it once when the panel's sections are /// assembled lets a `PropSyncActive` event map a focused text-input id back to -/// its field key in O(1) instead of re-scanning the sections. The block-Name -/// caret-dropdown (`EditChoice`) is editable but deliberately carries no id and -/// is excluded here, mirroring the renderer. +/// its field key in O(1) instead of re-scanning the sections, enabling +/// select-all-on-focus for both text inputs and edit-choices (e.g. transparency). pub fn build_field_key_map( sections: &[PropSection], ) -> HashMap { @@ -318,7 +317,9 @@ pub fn build_field_key_map( for section in sections { for prop in §ion.props { let key = match &prop.value { - PropValue::EditText(_) | PropValue::PlainText(_) => { + PropValue::EditText(_) + | PropValue::PlainText(_) + | PropValue::EditChoice { .. } => { Some(FieldKey::Geom(prop.field)) } PropValue::AttrText { tag, .. } => Some(FieldKey::Attr(tag.clone())), @@ -778,6 +779,9 @@ impl PropertiesPanel { render_annotative_scale_row(label, val) } PropValue::ReadOnly(val) => render_ro_row(label, val), + PropValue::ReadOnlyWithTooltip { value, tooltip } => { + render_ro_with_tooltip_row(label, value, tooltip) + } PropValue::HatchPatternChoice(current) => { self.render_hatch_pattern_row(label, current) } @@ -1102,20 +1106,21 @@ impl PropertiesPanel { entity_val: &'a str, options: &'a [String], ) -> Element<'a, Message> { - let typed = self.edit_buf.get(&FieldKey::Geom(field)); + let key = FieldKey::Geom(field); + let active = self.active_field.as_ref() == Some(&key); + let typed = self.edit_buf.get(&key); let display = typed.map(|s| s.as_str()).unwrap_or(entity_val); - // NOTE: unlike `render_edit_row` / `render_attr_row`, this input is - // deliberately NOT wrapped in a click-to-focus mouse_area. It is a - // caret-dropdown: clicking should place the caret / open the list, and - // the auto Select-all-on-focus would fight that. Don't "fix" it. let input = text_input("", display) + .id(prop_geom_field_id(field)) .on_input(move |v| Message::PropGeomInput { field, value: v }) .on_submit(Message::PropGeomCommit(field)) .size(FONT_SZ) .style(|theme: &Theme, status| text_input::Style { - // The wrapping container draws the border; keep the input flat - // so field + caret read as one control. + // The wrapping container draws the border and background; keep + // the input transparent and borderless so field + caret read as + // one continuous bordered box. + background: Background::Color(Color::TRANSPARENT), border: Border { color: Color::TRANSPARENT, width: 0.0, @@ -1123,55 +1128,69 @@ impl PropertiesPanel { }, ..text_input_style(theme, status) }) - .padding([3, 6]) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 6.0, + right: 2.0, + }) .width(Length::Fill); let caret = button( container(if self.edit_choice_open { - crate::ui::icons::themed_arrow_up(FONT_SZ) + crate::ui::icons::themed_arrow_up(9.0) } else { - crate::ui::icons::themed_arrow_down(FONT_SZ) + crate::ui::icons::themed_arrow_down(9.0) }) - .height(Length::Fill) .align_y(iced::Center), ) .on_press(Message::PropEditChoiceToggle) .style(|theme: &Theme, status| { let palette = theme.palette(); - let pair = match status { - button::Status::Hovered | button::Status::Pressed => palette.background.weak, - _ => palette.background.base, + let bg = match status { + button::Status::Hovered | button::Status::Pressed => { + Some(Background::Color(palette.background.weak.color)) + } + _ => None, + }; + let text_color = match status { + button::Status::Hovered | button::Status::Pressed => palette.background.weak.text, + _ => palette.background.base.text, }; button::Style { - background: Some(Background::Color(pair.color)), - text_color: pair.text, - border: Border::default(), - ..Default::default() + background: bg, + text_color, + border: Border::default(), + ..Default::default() } }) .padding(Padding { - top: 0.0, - bottom: 0.0, + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, left: 3.0, - right: 3.0, - }) - .height(Length::Fixed(ROW_H - 6.0)); + right: 4.0, + }); let head = container(row![input, caret].align_y(iced::Center)) - .style(|theme: &Theme| { + .style(move |theme: &Theme| { let palette = theme.palette(); + let border_color = if active { + palette.primary.base.color + } else { + palette.background.neutral.color + }; container::Style { - background: Some(Background::Color(palette.background.base.color)), - border: Border { - color: palette.background.neutral.color, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() + background: Some(Background::Color(palette.background.base.color)), + border: Border { + color: border_color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() } }) .width(Length::Fill); if !self.edit_choice_open { - return prop_row_widget(label, head.into()); + return prop_row_with_active(label, head.into(), active); } // Open list: all definitions, filtered by any typed text. @@ -1194,19 +1213,18 @@ impl PropertiesPanel { } let popup = container(scrollable(list).height(Length::Shrink)) .style(container::bordered_box) - .padding(2) - .width(200) - .height(Length::Fit.max(220.0)); + .padding(2); - prop_row_widget( + prop_row_with_active( label, crate::ui::color_select::drop_down_below( head.into(), popup.into(), - Length::Fixed(200.0), + None, Length::Shrink, Message::PropEditChoiceToggle, ), + active, ) } @@ -1381,7 +1399,7 @@ impl PropertiesPanel { crate::ui::color_select::drop_down_below( head.into(), popup.into(), - Length::Fixed(PATTERN_PICKER_W), + Some(Length::Fixed(PATTERN_PICKER_W)), Length::Fixed(PATTERN_PICKER_H), Message::PropHatchPatternPickerToggle(current.to_string()), ), @@ -1658,7 +1676,12 @@ fn coord_suffix(label: &str) -> Option<(&str, usize)> { /// labelled " X", " Y" and optionally " Z". 0/1 = no group. fn coord_group_len(props: &[crate::scene::model::object::Property], idx: usize) -> usize { let groupable = |p: &crate::scene::model::object::Property| { - matches!(p.value, PropValue::EditText(_) | PropValue::ReadOnly(_)) + matches!( + p.value, + PropValue::EditText(_) + | PropValue::ReadOnly(_) + | PropValue::ReadOnlyWithTooltip { .. } + ) }; let Some((base, 0)) = coord_suffix(&props[idx].label) else { return 0; @@ -1697,7 +1720,9 @@ fn coord_component(label: &str) -> &'static str { /// EditText / ReadOnly — see `coord_group_len`). fn prop_text_value(prop: &crate::scene::model::object::Property) -> String { match &prop.value { - PropValue::EditText(s) | PropValue::ReadOnly(s) => s.clone(), + PropValue::EditText(s) + | PropValue::ReadOnly(s) + | PropValue::ReadOnlyWithTooltip { value: s, .. } => s.clone(), _ => String::new(), } } @@ -1811,6 +1836,31 @@ fn render_ro_row<'a>(label: &'a str, value: &'a str) -> Element<'a, Message> { prop_row_widget(label, field) } +fn render_ro_with_tooltip_row<'a>( + label: &'a str, + value: &'a str, + tooltip_text: &'a str, +) -> Element<'a, Message> { + let field = crate::ui::read_only::field(value, FONT_SZ, Length::Fill); + let wrapped = tooltip(field, text(tooltip_text).size(FONT_SZ), tooltip::Position::Top) + .gap(4.0) + .padding(6.0) + .style(|theme: &Theme| { + let palette = theme.palette(); + container::Style { + background: Some(Background::Color(palette.background.base.color)), + text_color: Some(palette.background.base.text), + border: Border { + color: palette.background.neutral.color, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + } + }); + prop_row_widget(label, wrapped.into()) +} + /// Build a label | widget property row. fn prop_row_widget<'a>(label: &'a str, widget: Element<'a, Message>) -> Element<'a, Message> { prop_row_with_active(label, widget, false) @@ -2093,8 +2143,10 @@ mod tests { assert_eq!(map.get(&attr_id), Some(&attr_edit_key("TITLE"))); assert!(active_key_focused(map.get(&attr_id), Some(&attr_id))); - // The block-Name caret-dropdown is editable but deliberately excluded. - assert_eq!(map.get(&prop_geom_field_id("name")), None); + // The caret-dropdown (EditChoice) is editable and maps to its field key. + let name_id = prop_geom_field_id("name"); + assert_eq!(map.get(&name_id), Some(&FieldKey::Geom("name"))); + assert!(active_key_focused(map.get(&name_id), Some(&name_id))); // Unknown or non-property ids map to nothing. assert_eq!(map.get(&prop_geom_field_id("nope")), None); diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index e8b1ac3b..c15db127 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -11,7 +11,7 @@ use acadrust::entities::{Dimension, DimensionLinear, Text}; use acadrust::types::Vector3; use acadrust::EntityType; -use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions}; +use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire}; use OpenCADStudio::scene::Scene; #[test] @@ -39,14 +39,10 @@ fn text_and_dim_reach_pdf_export() { .iter() .any(|w| w.name == want && !w.text_verts.is_empty()) }; - assert!( - has_text(text_h), - "TEXT carries no glyph quads to the exporter" - ); + assert!(has_text(text_h), "TEXT entity emitted no text glyphs"); assert!( has_text(dim_h), - "DIMENSION carries no glyph quads to the exporter — dim text would be \ - missing from the PDF (#385)" + "DIMENSION entity emitted no measurement-value text glyphs" ); // End-to-end: the same wire set with and without text. A private temp dir @@ -55,9 +51,17 @@ fn text_and_dim_reach_pdf_export() { let dir = std::env::temp_dir().join(format!("ocs385-{}", std::process::id())); std::fs::create_dir_all(&dir).expect("temp dir"); + let plot_wires: Vec = wires + .iter() + .map(|w| PlotWire { + wire: w.clone(), + draw_depth: 0.0, + }) + .collect(); + let p_text = dir.join("with_text.pdf"); export_pdf( - &wires, + &plot_wires, &[], &[], 210.0, @@ -75,11 +79,10 @@ fn text_and_dim_reach_pdf_export() { let with_text = std::fs::read(&p_text).expect("read pdf"); assert!(with_text.starts_with(b"%PDF"), "not a PDF"); - let stripped: Vec<_> = wires - .iter() - .cloned() + let stripped: Vec<_> = plot_wires + .into_iter() .map(|mut w| { - w.text_verts.clear(); + w.wire.text_verts.clear(); w }) .collect(); diff --git a/tests/text_font_rendering.rs b/tests/text_font_rendering.rs index b0287a78..eb741373 100644 --- a/tests/text_font_rendering.rs +++ b/tests/text_font_rendering.rs @@ -77,6 +77,7 @@ fn expand_block_mtext( }, 0, false, + false, 1.0, None, None, From 00b3ef54ebd0e8796e456ef106c54a58c89c2957 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:29:44 +0300 Subject: [PATCH 19/98] fix(donut): complete diameter workflow --- locales/en-US/opencadstudio.ftl | 2 + locales/tr-TR/opencadstudio.ftl | 2 + src/app/commands/styleprops.rs | 47 ++++++++- src/locale_catalog.rs | 2 + src/modules/draw/defaults.rs | 12 +++ src/modules/draw/draw/donut.rs | 171 +++++++++++++++++++++++++++----- 6 files changed, 208 insertions(+), 28 deletions(-) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 71bd8ff2..0c36076a 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1271,6 +1271,8 @@ draw = .donut-specify-center-of-donut-enter-to-exit = DONUT Specify center of donut (Enter to exit): .donut-specify-inside-diameter-0 = DONUT Specify inside diameter <0>: .donut-specify-outside-diameter = DONUT Specify outside diameter: + .donut-specify-second-point-for-inside-diameter = DONUT Specify second point for inside diameter: + .donut-specify-second-point-for-outside-diameter = DONUT Specify second point for outside diameter: .draw-manually = Draw manually .ellipse-specify-center = ELLIPSE Specify center: .ellipse-specify-major-axis-endpoint = ELLIPSE Specify major axis endpoint: diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 2f44e370..8a85cf23 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1253,6 +1253,8 @@ draw = .donut-specify-center-of-donut-enter-to-exit = DONUT Halkanın merkezini belirtin (çıkmak için Enter): .donut-specify-inside-diameter-0 = DONUT İç çapı belirtin <0>: .donut-specify-outside-diameter = DONUT Dış çapı belirtin: + .donut-specify-second-point-for-inside-diameter = DONUT İç çap için ikinci noktayı belirtin: + .donut-specify-second-point-for-outside-diameter = DONUT Dış çap için ikinci noktayı belirtin: .draw-manually = Elle çiz .ellipse-specify-center = ELLIPSE Merkezi belirtin: .ellipse-specify-major-axis-endpoint = ELLIPSE Büyük eksen uç noktasını belirtin: diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 8f746a2a..f4bbdc1b 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -935,6 +935,8 @@ impl OpenCADStudio { | "SKETCHINC" | "SKPOLY" | "SKTOLERANCE" + | "DONUTID" + | "DONUTOD" | "CENTEREXE" | "CENTERLAYER" | "CENTERLTYPE" @@ -965,7 +967,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { let frame_kind = crate::scene::frame::kind_for_name(&name); @@ -1081,6 +1083,49 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if matches!(name.as_str(), "DONUTID" | "DONUTOD") { + let current = if name == "DONUTID" { + crate::modules::draw::defaults::get_donut_inner_diameter() + } else { + crate::modules::draw::defaults::get_donut_outer_diameter() + }; + if let Some(value) = &value { + let parsed = value + .trim() + .replace(',', ".") + .parse::() + .ok() + .filter(|number| number.is_finite()); + let valid = parsed.filter(|number| { + if name == "DONUTID" { + *number >= 0.0 + } else { + *number > 0.0 + } + }); + if let Some(number) = valid { + if name == "DONUTID" { + crate::modules::draw::defaults::set_donut_inner_diameter(number); + } else { + crate::modules::draw::defaults::set_donut_outer_diameter(number); + } + self.command_line + .push_output(&format!("{name} = {number}")); + } else { + self.command_line.push_error(if name == "DONUTID" { + "SETVAR: DONUTID requires a finite value greater than or equal to zero." + } else { + "SETVAR: DONUTOD requires a finite value greater than zero." + }); + } + } else { + self.command_line.push_output(crate::tf!( + "Enter new value for {name} <{current}>:" + ).as_ref()); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } // Parse a boolean given as 0/1 or ON/OFF. let parse_bool = |s: &str| match s.to_uppercase().as_str() { "1" | "ON" | "TRUE" => Some(true), diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 3a7fce6f..0613eb00 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -786,6 +786,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DONUT Specify center of donut (Enter to exit):" => Some(("draw", "donut-specify-center-of-donut-enter-to-exit")), "DONUT Specify inside diameter <0>:" => Some(("draw", "donut-specify-inside-diameter-0")), "DONUT Specify outside diameter:" => Some(("draw", "donut-specify-outside-diameter")), + "DONUT Specify second point for inside diameter:" => Some(("draw", "donut-specify-second-point-for-inside-diameter")), + "DONUT Specify second point for outside diameter:" => Some(("draw", "donut-specify-second-point-for-outside-diameter")), "DRAWORDER Select reference object (move selection above):" => Some(("view", "draworder-select-reference-object-move-selection-above")), "DRAWORDER Select reference object (move selection under):" => Some(("view", "draworder-select-reference-object-move-selection-under")), "DRAWORDER [Front / Back] (Above/Under by typing):" => Some(("view", "draworder-front-back-above-under-handle-by-typ")), diff --git a/src/modules/draw/defaults.rs b/src/modules/draw/defaults.rs index a3598fa8..f21a40f8 100644 --- a/src/modules/draw/defaults.rs +++ b/src/modules/draw/defaults.rs @@ -26,6 +26,8 @@ thread_local! { static RECT_CHAMFER1: Cell = Cell::new(0.0); static RECT_CHAMFER2: Cell = Cell::new(0.0); static RECT_FILLET: Cell = Cell::new(0.0); + static DONUT_INNER_DIAMETER: Cell = Cell::new(0.5); + static DONUT_OUTER_DIAMETER: Cell = Cell::new(1.0); } macro_rules! accessors { @@ -68,3 +70,13 @@ accessors!(get_rect_rotation, set_rect_rotation, RECT_ROTATION); accessors!(get_rect_chamfer1, set_rect_chamfer1, RECT_CHAMFER1); accessors!(get_rect_chamfer2, set_rect_chamfer2, RECT_CHAMFER2); accessors!(get_rect_fillet, set_rect_fillet, RECT_FILLET); +accessors!( + get_donut_inner_diameter, + set_donut_inner_diameter, + DONUT_INNER_DIAMETER +); +accessors!( + get_donut_outer_diameter, + set_donut_outer_diameter, + DONUT_OUTER_DIAMETER +); diff --git a/src/modules/draw/draw/donut.rs b/src/modules/draw/draw/donut.rs index c459492a..c0a3611d 100644 --- a/src/modules/draw/draw/donut.rs +++ b/src/modules/draw/draw/donut.rs @@ -2,7 +2,7 @@ // // A donut is an LwPolyline with: // - 2 vertices at (cx ± r_avg, 0), both with bulge = 1.0 (two 180° CCW arcs) -// - start_width = end_width = (outer - inner) / 2 +// - constant_width = (outer - inner) / 2 // - is_closed = true // // Workflow: @@ -12,10 +12,15 @@ use acadrust::entities::{LwPolyline, LwVertex}; use acadrust::EntityType; +use cadkernel::geom2d::{Circle as KernelCircle, Curve as KernelCurve}; use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::modules::draw::defaults; +use crate::scene::model::wire_model::WireModel; + +const TAU: f64 = std::f64::consts::TAU; pub struct DonutCommand { state: DonutState, @@ -24,21 +29,51 @@ pub struct DonutCommand { plane: WorkingPlane, } +#[derive(Clone, Copy)] enum DonutState { AskInner, + AskInnerSecond(DVec3), AskOuter, + AskOuterSecond(DVec3), PlaceCenter, } impl DonutCommand { pub fn new() -> Self { + let mut inner_diameter = defaults::get_donut_inner_diameter(); + let mut outer_diameter = defaults::get_donut_outer_diameter(); + if inner_diameter > outer_diameter { + std::mem::swap(&mut inner_diameter, &mut outer_diameter); + defaults::set_donut_inner_diameter(inner_diameter); + defaults::set_donut_outer_diameter(outer_diameter); + } Self { state: DonutState::AskInner, - inner_r: 0.0, - outer_r: 1.0, + inner_r: inner_diameter * 0.5, + outer_r: outer_diameter * 0.5, plane: WorkingPlane::default(), } } + + fn set_inner_diameter(&mut self, diameter: f64) { + self.inner_r = diameter * 0.5; + self.state = DonutState::AskOuter; + } + + fn set_outer_diameter(&mut self, diameter: f64) { + self.outer_r = diameter * 0.5; + if self.inner_r > self.outer_r { + std::mem::swap(&mut self.inner_r, &mut self.outer_r); + } + defaults::set_donut_inner_diameter(self.inner_r * 2.0); + defaults::set_donut_outer_diameter(self.outer_r * 2.0); + self.state = DonutState::PlaceCenter; + } + + fn point_distance(&self, first: DVec3, second: DVec3) -> f64 { + let delta = self.plane.vector_to_local(second - first); + delta.x.hypot(delta.y) + } } impl CadCommand for DonutCommand { @@ -51,9 +86,20 @@ impl CadCommand for DonutCommand { } fn prompt(&self) -> String { - match &self.state { - DonutState::AskInner => t!("DONUT Specify inside diameter <0>:").into_owned(), - DonutState::AskOuter => t!("DONUT Specify outside diameter:").into_owned(), + match self.state { + DonutState::AskInner => t!("DONUT Specify inside diameter <0>:") + .replace("<0>", &format!("<{:.4}>", self.inner_r * 2.0)), + DonutState::AskInnerSecond(_) => { + t!("DONUT Specify second point for inside diameter:").into_owned() + } + DonutState::AskOuter => format!( + "{} <{:.4}>:", + t!("DONUT Specify outside diameter:").trim_end_matches(':'), + self.outer_r * 2.0 + ), + DonutState::AskOuterSecond(_) => { + t!("DONUT Specify second point for outside diameter:").into_owned() + } DonutState::PlaceCenter => { t!("DONUT Specify center of donut (Enter to exit):").into_owned() } @@ -78,22 +124,20 @@ impl CadCommand for DonutCommand { .replace(',', ".") .parse() .ok() - .filter(|&v: &f64| v >= 0.0)?; - match &self.state { + .filter(|v: &f64| v.is_finite())?; + match self.state { DonutState::AskInner => { - self.inner_r = val / 2.0; - self.state = DonutState::AskOuter; + if val < 0.0 { + return Some(CmdResult::NeedPoint); + } + self.set_inner_diameter(val); Some(CmdResult::NeedPoint) } DonutState::AskOuter => { if val <= 0.0 { - return None; + return Some(CmdResult::NeedPoint); } - self.outer_r = val / 2.0; - if self.inner_r > self.outer_r { - std::mem::swap(&mut self.inner_r, &mut self.outer_r); - } - self.state = DonutState::PlaceCenter; + self.set_outer_diameter(val); Some(CmdResult::NeedPoint) } _ => None, @@ -101,29 +145,106 @@ impl CadCommand for DonutCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { - match &self.state { + match self.state { + DonutState::AskInner => { + self.state = DonutState::AskInnerSecond(pt); + CmdResult::NeedPoint + } + DonutState::AskInnerSecond(first) => { + let diameter = self.point_distance(first, pt); + if diameter <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.set_inner_diameter(diameter); + CmdResult::NeedPoint + } + DonutState::AskOuter => { + self.state = DonutState::AskOuterSecond(pt); + CmdResult::NeedPoint + } + DonutState::AskOuterSecond(first) => { + let diameter = self.point_distance(first, pt); + if diameter <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.set_outer_diameter(diameter); + CmdResult::NeedPoint + } DonutState::PlaceCenter => { let center = self.plane.to_local(pt); let entity = make_donut(center.x, center.y, center.z, self.inner_r, self.outer_r); // Keep command active so user can place more donuts. CmdResult::CommitEntity(self.plane.place_entity(entity)) } - _ => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - match &self.state { + match self.state { DonutState::AskInner => { - // Accept default 0 for inner diameter - self.inner_r = 0.0; self.state = DonutState::AskOuter; CmdResult::NeedPoint } + DonutState::AskOuter => { + self.set_outer_diameter(self.outer_r * 2.0); + CmdResult::NeedPoint + } DonutState::PlaceCenter => CmdResult::Cancel, _ => CmdResult::Cancel, } } + + fn on_mouse_move(&mut self, pt: DVec3) -> Option { + match self.state { + DonutState::AskInnerSecond(first) | DonutState::AskOuterSecond(first) => { + Some(WireModel::solid_f64( + "rubber_band".into(), + vec![first.to_array(), pt.to_array()], + WireModel::CYAN, + false, + )) + } + DonutState::PlaceCenter => Some(donut_wire( + pt, + self.inner_r, + self.outer_r, + self.plane, + )), + _ => None, + } + } +} + +fn donut_wire( + center: DVec3, + inner_r: f64, + outer_r: f64, + plane: WorkingPlane, +) -> WireModel { + let local_center = plane.to_local(center); + let mut points = Vec::new(); + for radius in [outer_r, inner_r] { + if radius <= 1.0e-9 { + continue; + } + if !points.is_empty() { + points.push([f64::NAN; 3]); + } + points.extend( + KernelCurve::Circle(KernelCircle { + centre: [local_center.x, local_center.y], + radius, + }) + .tessellate_angle(TAU / 64.0) + .into_iter() + .map(|point| { + plane + .to_world(DVec3::new(point[0], point[1], local_center.z)) + .to_array() + }), + ); + } + WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false) } fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> EntityType { @@ -136,17 +257,13 @@ fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> E p.constant_width = width; p.elevation = elevation; - // Vertex at (cx - r, cy) with bulge 1.0 (180° CCW arc to next vertex) + // Constant width is stored once on the polyline. Per-segment width fields + // stay zero so a later Global width edit controls the whole ring. let mut v0 = LwVertex::new(Vector2::new(cx - r_avg, cy)); v0.bulge = 1.0; - v0.start_width = width; - v0.end_width = width; - // Vertex at (cx + r, cy) with bulge 1.0 (second 180° arc back to v0) let mut v1 = LwVertex::new(Vector2::new(cx + r_avg, cy)); v1.bulge = 1.0; - v1.start_width = width; - v1.end_width = width; p.vertices = vec![v0, v1]; EntityType::LwPolyline(p) From 8c56c033870d3795611b066159e20a85e8805717 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 09:36:57 +0300 Subject: [PATCH 20/98] fix(polyline): honor global width and fill mode --- src/entities/lwpolyline.rs | 31 ++++++++++++++++++++++++++++--- src/scene/pipeline/uniforms.rs | 3 ++- src/scene/view/render.rs | 1 + src/shaders/block_wire.wgsl | 26 +++++++++++++++++++++++++- src/shaders/wire.wgsl | 28 +++++++++++++++++++++++++++- src/shaders/wire_indexed.wgsl | 25 ++++++++++++++++++++++++- 6 files changed, 107 insertions(+), 7 deletions(-) diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index e51bc8bb..f74588c5 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -964,7 +964,16 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { }; match field { "elevation" => pline.elevation = v, - "global_width" => pline.constant_width = v, + "global_width" if v.is_finite() && v >= 0.0 => { + pline.constant_width = v; + // Global width replaces every segment override. Keeping old + // start/end values would make this editable row appear to work + // while the renderer continued using the stale per-segment data. + for vertex in &mut pline.vertices { + vertex.start_width = 0.0; + vertex.end_width = 0.0; + } + } "vertex_x" => { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.location.x = v; @@ -975,12 +984,28 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { vtx.location.y = v; } } - "start_width" => { + "start_width" if v.is_finite() && v >= 0.0 => { + if pline.constant_width > 0.0 { + let width = pline.constant_width; + for vertex in &mut pline.vertices { + vertex.start_width = width; + vertex.end_width = width; + } + pline.constant_width = 0.0; + } if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.start_width = v; } } - "end_width" => { + "end_width" if v.is_finite() && v >= 0.0 => { + if pline.constant_width > 0.0 { + let width = pline.constant_width; + for vertex in &mut pline.vertices { + vertex.start_width = width; + vertex.end_width = width; + } + pline.constant_width = 0.0; + } if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.end_width = v; } diff --git a/src/scene/pipeline/uniforms.rs b/src/scene/pipeline/uniforms.rs index c493081b..29326992 100644 --- a/src/scene/pipeline/uniforms.rs +++ b/src/scene/pipeline/uniforms.rs @@ -25,7 +25,8 @@ pub struct Uniforms { /// zooming inside MSPACE changes one scalar instead of re-tessellating and /// re-uploading every dashed wire. pub linetype_scale: f32, - /// Pads the struct to the uniform alignment required by wgpu. + /// FILLMODE toggle. Wire shaders use this slot to draw wide polylines as + /// boundary outlines when fills are disabled. pub _pad: f32, // ── Relative-to-eye (double-single) additions ─────────────────────────── diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 29a9f25c..b09fb5db 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3527,6 +3527,7 @@ impl Scene { }; let mut uniforms = Uniforms::new(&inst.camera, full_bounds, self.document.header.lineweight_display); + uniforms._pad = if self.document.header.fill_mode { 1.0 } else { 0.0 }; if self.document.header.paper_space_linetype_scaling && !inst.paper_sheet && inst.tile_idx.is_none() diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index 29e5bd25..79231f87 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -5,7 +5,7 @@ struct Uniforms { flat_shade: f32, transparency_enable: f32, linetype_scale: f32, - _pad: f32, + fillmode_enable: f32, view_rot: mat4x4, eye_high: vec3, _pad_eh: f32, @@ -58,6 +58,7 @@ struct VertexOut { @location(7) @interpolate(flat) align_total: f32, @location(8) cap: vec2, @location(9) @interpolate(flat) cap_ends: vec3, + @location(10) @interpolate(flat) world_band: f32, } fn resolve_hw(taper_ratio: f32, world_hw: f32, px_hw: f32) -> f32 { @@ -175,6 +176,11 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut half_width * side, ); out.cap_ends = vec3(segment_length, half_width_a, half_width_b); + out.world_band = select( + 0.0, + 1.0, + wire_const.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0 + ); return out; } @@ -244,10 +250,28 @@ fn clipped_cap(cap: vec2, ends: vec3) -> bool { return false; } +fn band_interior_clipped(in: VertexOut) -> bool { + if u.fillmode_enable > 0.5 || in.world_band < 0.5 { + return false; + } + if in.cap.x < 0.0 || in.cap.x > in.cap_ends.x { + return true; + } + var along = 0.0; + if in.cap_ends.x > 1e-4 { + along = clamp(in.cap.x / in.cap_ends.x, 0.0, 1.0); + } + let half_width = mix(in.cap_ends.y, in.cap_ends.z, along); + return abs(in.cap.y) < max(half_width - 1.0, 0.0); +} + fn visible_fragment(in: VertexOut) -> bool { if clipped_cap(in.cap, in.cap_ends) { return false; } + if band_interior_clipped(in) { + return false; + } if in.pattern_length > 0.0 && in.min_elem >= u.world_per_pixel { return in_dash( in.distance, diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index c8fac652..747fb574 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -29,7 +29,7 @@ struct Uniforms { // every line opaque. transparency_enable: f32, linetype_scale: f32, - _pad: f32, + fillmode_enable: f32, // ── Relative-to-eye (double-single) ────────────────────────────────── // view_rot is the rotation-only view-projection; vertices subtract the eye // (eye_high + eye_low, two f32 emulating f64) before transforming, so the @@ -94,6 +94,7 @@ struct VertexOut { @location(8) cap: vec2, // (segment pixel length, end half-width at A, end half-width at B). @location(9) @interpolate(flat) cap_ends: vec3, + @location(10) @interpolate(flat) world_band: f32, } // Half-width of one segment end: a tapered band's own end width wins, then a @@ -215,6 +216,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: + ext * hw * u.world_per_pixel; out.cap = vec2(which_end * seg_len + ext * hw, hw * side); out.cap_ends = vec3(seg_len, hw_a, hw_b); + out.world_band = select(0.0, 1.0, in.misc.w > 0.0 || in.taper.x > 0.0 || in.taper.y > 0.0); out.pattern_length = in.dists.w * lt_scale; out.pat0 = in.pat0 * lt_scale; out.pat1 = in.pat1 * lt_scale; @@ -287,6 +289,24 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { return false; } +fn band_interior_clipped(in: VertexOut) -> bool { + if u.fillmode_enable > 0.5 || in.world_band < 0.5 { + return false; + } + // Wide-polyline fill off: retain a one-pixel line at both band edges. + // Segment cap overhangs are discarded; adjacent sampled segments meet at + // their shared endpoint without drawing an interior seam across the band. + if in.cap.x < 0.0 || in.cap.x > in.cap_ends.x { + return true; + } + var along = 0.0; + if in.cap_ends.x > 1e-4 { + along = clamp(in.cap.x / in.cap_ends.x, 0.0, 1.0); + } + let half_width = mix(in.cap_ends.y, in.cap_ends.z, along); + return abs(in.cap.y) < max(half_width - 1.0, 0.0); +} + @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { // Negative pattern length is the persistent-arena tombstone sentinel. // Discard before cap/alpha work so deleted slabs cannot write color/depth. @@ -296,6 +316,9 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { if cap_clipped(in.cap, in.cap_ends) { discard; } + if band_interior_clipped(in) { + discard; + } if in.pattern_length > 0.0 { // LOD: once the pattern's smallest feature drops below ~1 px // on screen, dash gaps alias / shimmer (or vanish completely) @@ -323,6 +346,9 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { if cap_clipped(in.cap, in.cap_ends) { discard; } + if band_interior_clipped(in) { + discard; + } if in.pattern_length > 0.0 { if in.min_elem >= u.world_per_pixel { if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) { diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index c62d92bd..0e0acfda 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -12,7 +12,7 @@ struct Uniforms { flat_shade: f32, transparency_enable: f32, linetype_scale: f32, - _pad: f32, + fillmode_enable: f32, view_rot: mat4x4, eye_high: vec3, _pad_eh: f32, @@ -71,6 +71,7 @@ struct VertexOut { @location(8) cap: vec2, // (segment pixel length, end half-width at A, end half-width at B). @location(9) @interpolate(flat) cap_ends: vec3, + @location(10) @interpolate(flat) world_band: f32, } // Half-width of one segment end: a tapered band's own end width wins, then a @@ -181,6 +182,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon + ext * hw * u.world_per_pixel; out.cap = vec2(which_end * seg_len + ext * hw, hw * side); out.cap_ends = vec3(seg_len, hw_a, hw_b); + out.world_band = select(0.0, 1.0, c.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0); out.pattern_length = c.pattern_length * lt_scale; out.pat0 = c.pat0 * lt_scale; out.pat1 = c.pat1 * lt_scale; @@ -244,6 +246,21 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { return false; } +fn band_interior_clipped(in: VertexOut) -> bool { + if u.fillmode_enable > 0.5 || in.world_band < 0.5 { + return false; + } + if in.cap.x < 0.0 || in.cap.x > in.cap_ends.x { + return true; + } + var along = 0.0; + if in.cap_ends.x > 1e-4 { + along = clamp(in.cap.x / in.cap_ends.x, 0.0, 1.0); + } + let half_width = mix(in.cap_ends.y, in.cap_ends.z, along); + return abs(in.cap.y) < max(half_width - 1.0, 0.0); +} + @fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { // Negative pattern length is the persistent-arena tombstone sentinel. // Discard before cap/alpha work so deleted slabs cannot write color/depth. @@ -253,6 +270,9 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { if cap_clipped(in.cap, in.cap_ends) { discard; } + if band_interior_clipped(in) { + discard; + } if in.pattern_length > 0.0 { if in.min_elem >= u.world_per_pixel { if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) { @@ -274,6 +294,9 @@ fn cap_clipped(cap: vec2, cap_ends: vec3) -> bool { if cap_clipped(in.cap, in.cap_ends) { discard; } + if band_interior_clipped(in) { + discard; + } if in.pattern_length > 0.0 { if in.min_elem >= u.world_per_pixel { if !in_dash(in.distance, in.pattern_length, in.pat0, in.pat1, in.align_end, in.align_total) { From 7c77d78b46927d17eb8cf1fb96cd2f7137024c8b Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Mon, 24 Aug 2026 08:46:34 +0200 Subject: [PATCH 21/98] Update opencadstudio.ftl Line 1449 --- locales/de-DE/opencadstudio.ftl | 250 ++++++++++++++++---------------- 1 file changed, 125 insertions(+), 125 deletions(-) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index e4d3ec28..c805286f 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -70,7 +70,7 @@ start = .donate = Spenden .send-feedback = Rückmeldung senden .sponsors = Sponsoren - .tutorials = Lernprogramme + .tutorials = Lerneinheiten .loading-videos = Videos werden geladen… .videos-online = Videos werden aus dem Internet geladen. .open-playlist = Wiedergabeliste auf YouTube öffnen @@ -843,11 +843,11 @@ common = .text-height-label-sentence-case = Texthöhe: .text-movement = Textbewegung .text-movement-dimtmove = Textbewegung (DIMTMOVE) - .text-out-first = Text zuerst raus + .text-out-first = Text abheben .text-style = Textstil - .text-to-find = Text zu finden - .the-selected-object-does-not-support-annotatio = Das ausgewählte Objekt unterstützt keine Annotationsskalen. - .tick-size-dimtsz = Tickgröße (DIMTSZ) + .text-to-find = Text finden + .the-selected-object-does-not-support-annotatio = Das ausgewählte Objekt unterstützt keine Beschriftungsskalierung. + .tick-size-dimtsz = Hakengröße (DIMTSZ) .title = Titel .title-row = Titelzeile: .title-row-suppressed = Titelzeile unterdrückt @@ -856,11 +856,11 @@ common = .toggle-object-snap-tracking = Objektfangspur umschalten .toggle-ortho = Orthomodus umschalten .toggle-polar-tracking = Polarspur umschalten - .tol-decimals-dimalttd = Tol-Dezimalstellen (DIMALTTD) - .tol-zero-supp-dimalttz = Nullunterdrückung der Toleranz (DIMALTTZ) - .tol-decimals-dimtdec = Tol. Dezimalstellen (DIMTDEC) + .tol-decimals-dimalttd = Toleranz - Dezimalstellen (DIMALTTD) + .tol-zero-supp-dimalttz = Toleranz - Nullen ausblenden (DIMALTTZ) + .tol-decimals-dimtdec = Toleranz - Dezimalstellen (DIMTDEC) .tol-vert-just-dimtolj = Vertikale Toleranzausrichtung (DIMTOLJ) - .tol-zero-supp-dimtzin = Nullunterdrückung der Toleranz (DIMTZIN) + .tol-zero-supp-dimtzin = Toleranz - Nullen ausblenden (DIMTZIN) .tolerance = Toleranz .tolerance-precision = Toleranzgenauigkeit .tolerances = Toleranzen @@ -871,23 +871,23 @@ common = .top-center-title-case = Oben zentriert .top-left-title-case = Oben links .top-right-title-case = Oben rechts - .top-attachment = Oberbefestigung: - .top-center-sentence-case = Oberes Zentrum + .top-attachment = Befestigung oben: + .top-center-sentence-case = Oben mittig .top-left-sentence-case = Oben links .top-right-sentence-case = Oben rechts .top-to-bottom = Von oben nach unten - .topcenter = Oberes Zentrum + .topcenter = Oben mittig .topleft = Oben links .topright = Oben rechts .torus = Torus .transparency = Transparenz .truetype-system = TrueType (System) .ttr = Ttr - .tutorials = Lernprogramme + .tutorials = Lerneinheiten .type = Typ - .ucs-world = Welt-BKS + .ucs-world = WKS .ucs = UCS: __ocs_fmt_0__ - .unable-to-change-ui-language-error = Unfähig, die UI Sprache zu ändern: __ocs_fmt_0__ + .unable-to-change-ui-language-error = Unmöglich die UI Sprache zu ändern: __ocs_fmt_0__ .undo = Rückgängig .undo-last-label = Rückgängig: __ocs_fmt_0__ .uniform-scale = Einheitliche Skala @@ -899,7 +899,7 @@ common = .unsaved-changes = Ungespeicherte Änderungen .unsupported-file-type = Nicht unterstützter Dateityp: __ocs_fmt_0__ .up = Aufwärts - .update-available = Update verfügbar + .update-available = Aktualisierung verfügbar .upside-down = Kopfüber nach unten .v-margin = Vertikaler Rand: .vplayer-frozen-count-layer-s-frozen-in-viewpor = VPLAYER: __ocs_fmt_0__ Ebene(n) im Ansichtsfenster eingefroren. @@ -910,7 +910,7 @@ common = .vertical-pos-dimtad = Vertikale Pos (DIMTAD) .vertical-pos-dimtvp = Vertikale Pos (DIMTVP) .videos = Videos - .videos-load-from-the-internet = Videos aus dem Internet geladen. + .videos-load-from-the-internet = Videos aus dem Internet laden. .view = Ansicht .view-home = Ansicht: Start .view-value = Ansicht: __ocs_fmt_0__ @@ -928,7 +928,7 @@ common = .width = Breite .width-factor-title-case = Breitenfaktor .width-factor-label-title-case = Breitenfaktor: - .width-factor-sentence-case = Witwenfaktor + .width-factor-sentence-case = Breitenfaktor .window = Fenster .wipeout = Abdeckung .wireframe = Drahtmodell @@ -938,28 +938,28 @@ common = .xline = XLine .xref-not-found = XREF Nicht gefunden: "__ocs_fmt_0__" (__ocs_fmt_1__) .xref-unloaded-skipped = XREF Unloaded (übersprungen): "__ocs_fmt_0__" - .yards = Gärten + .yards = Yards .yellow = Gelb .yes = Ja - .zero-suppress-dimaltz = Nullunterdrückung (DIMALTZ) - .zero-suppress-dimzin = Nullunterdrückung (DIMZIN) - .zoom-extents = Zoomausdehnungen - .zoom-in = In den Warenkorb - .zoom-out = Ausverkauft - .zoom-window = Zoomfenster + .zero-suppress-dimaltz = Nullen ausblenden (DIMALTZ) + .zero-suppress-dimzin = Nullen ausblenden (DIMZIN) + .zoom-extents = Ausdehnung anpassen + .zoom-in = Vergrößern + .zoom-out = Verkleinern + .zoom-window = Ausdehnungsfenster .command-lowercase = Befehl .e-g-arial = z. B. Arial - .linetype-lowercase = linienförmig - .lineweight-lowercase = Streckengewicht - .object-lowercase = Objekt - .options-lowercase = Optionen + .linetype-lowercase = Kleinschreibung Typ + .lineweight-lowercase = Kleinschreibung Gewicht + .object-lowercase = Kleinschreibung Objekt + .options-lowercase = Kleinschreibung Optionen .units = Einheiten .count-object-s-added-to-selection = __ocs_fmt_0__ Objekt(e) zur Auswahl hinzugefügt. .count-object-s-pasted = __ocs_fmt_0__ Objekt(e) eingefügt. .label-count-noun-created = __ocs_fmt_0__: __ocs_fmt_1__ __ocs_fmt_2__ erstellt. .acquired = __ocs_fmt_0__ erworben. .alias-es-applied = __ocs_fmt_0__ Aliasname(n) angewendet. - .group-s-dissolved = __ocs_fmt_0__ Gruppe(n) gelöst. + .group-s-dissolved = __ocs_fmt_0__ Gruppe(n) aufgelöst. .layer-s-turned = __ocs_fmt_0__ Layer(s) gedreht __ocs_fmt_1__ .layer-s = __ocs_fmt_0__ Ebene(n) __ocs_fmt_1__ .mod-shortcuts = ── __ocs_arg_MOD__-Tastenkürzel ──────────────────────── @@ -981,22 +981,22 @@ command = .cancel = *Abbrechen* .add-alias = + Aliasnamen hinzufügen .alias-title-case = Aliasname - .cannot-add-a-vertex-here = Kann hier keinen Scheitelpunkt hinzufügen. - .cannot-break-at-this-vertex = Kann nicht an diesem Scheitelpunkt brechen. - .cannot-place-the-new-vertex = Kann den neuen Scheitelpunkt nicht platzieren. + .cannot-add-a-vertex-here = Kann hier keinen Stützpunkt hinzufügen. + .cannot-break-at-this-vertex = Kann nicht an diesem Stützpunkt brechen. + .cannot-place-the-new-vertex = Kann den neuen Stützpunkt nicht platzieren. .command = Befehl - .command-aliases = Befehls Aliasnamen + .command-aliases = Befehls-Aliasnamen .key = Schlüssel .keyboard-shortcuts = Tastenkombinationen .layer-0-and-the-current-layer-can-t-be-deleted = Layer "0" und die aktuelle Ebene können nicht gelöscht werden - übersprungen. .no-drawing-open-use-new-or-open-to-start-a-dra = Verwenden Sie NEW oder OPEN, um eine Zeichnung zu starten. .object-is-on-locked-layer-layer-unlock-the-lay = Objekt befindet sich auf gesperrter Ebene "__ocs_fmt_0__" - sperren Sie die Ebene frei, um ihre Attribute zu bearbeiten. .pan-ended = PAN beendet. - .polyline-broken-at-vertex = Polylinie gebrochen an Scheitelpunkt. + .polyline-broken-at-vertex = Polylinie am Stützpunkt gebrochen. .rename-select-the-object-type-to-rename = RENAME Wählen Sie den umzubenennenden Objekttyp: .rename-type-enter-the-current-name = RENAME __ocs_arg_type__ Geben Sie den aktuellen Namen ein: .rename-type-rename-old-to = RENAME __ocs_arg_type__ „__ocs_arg_old__“ umbenennen in: - .specify-new-vertex-location = Neue Scheitelpunkt-Position angeben: + .specify-new-vertex-location = Neue Stützpunktposition angeben: .type-shortcuts-set-key-cmd-to-add-custom-short = Geben Sie SHORTCUTS SET ein, um benutzerdefinierte Verknüpfungen hinzuzufügen. .type-an-alias-and-the-command-it-runs-e-g-l-li = Alias und zugehörigen Befehl eingeben (z. B. L → LINE). Mit Anwenden in ocad.pgp speichern; Schließen verwirft nicht angewendete Änderungen. .unknown-command-cmd = Unbekannter Befehl: __ocs_fmt_0__ @@ -1009,21 +1009,21 @@ command = .tcount-placement-overwrite-prefix-suffix-overw = TCOUNT Platzierung [Überschreiben/Präfix/Suffix] <Überschreiben>: file = - .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = parse __ocs_fmt_0__ms · Purge __ocs_fmt_1__ms · Caches __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · total __ocs_fmt_4__ms + .parse-ms-purge-ms-caches-ms-xref-ms-total-ms = analysiere __ocs_fmt_0__ms · Säubere __ocs_fmt_1__ms · Puffer __ocs_fmt_2__ms · xref __ocs_fmt_3__ms · gesamt __ocs_fmt_4__ms .archive-save-the-drawing-first-it-has-no-file = ARCHIVE: Speichern Sie die Zeichnung zuerst (sie hat noch keinen Dateipfad). .autosaved-1-drawing = 1 Zeichnung automatisch gespeichert .background-colour-default-black-darkgray-gray = BACKGROUND Farbe [Standard/Black/DarkGray/Gray/LightGray/White] oder R G B (0–255): - .background-reset-to-default = Background auf Standard zurückgesetzt. + .background-reset-to-default = Hintergrund auf Standard zurückgesetzt. .background-rgb = Hintergrund: rgb(__ocs_fmt_0__, __ocs_fmt_1__, __ocs_fmt_2__) .created-new-ctb-table-aci-aci-updated = Neue CTB Tabelle erstellt, ACI __ocs_fmt_0__ aktualisiert. .default-save-format = Standard-Speicherformat: .do-you-want-to-save-changes-to-name = Möchten Sie Änderungen an "__ocs_arg_name__" speichern? .drawing-changed-on-disk = Zeichnung auf dem Datenträger geändert .drawing-units-insunits-click-to-change = - Zeicheneinheiten (INSUNITS) - Klicken Sie auf Change - .drawing-changed-on-disk-while-it-was-opening-s = Zeichnen auf der Festplatte geändert, während es geöffnet wurde; Speichern erfordert Konfliktlösung. - .edit-lease-active-warning-external-change-chec = Leasing aktiv bearbeiten; __ocs_fmt_0__. Externe Wechselprüfungen bleiben aktiv. + Zeichnungseinheiten (INSUNITS) + Klicken Sie auf Ändern + .drawing-changed-on-disk-while-it-was-opening-s = Zeichnung während es geöffnet wurde auf der Festplatte geändert; Speichern erfordert Konfliktlösung. + .edit-lease-active-warning-external-change-chec = Pacht aktiv bearbeiten; __ocs_fmt_0__. Externe Wechselprüfungen bleiben aktiv. .files-you-open-will-show-up-here = Dateien, die Sie öffnen, werden hier angezeigt. .new-drawing = Neue Zeichnung .no-plot-style-table-loaded-load-or-create-one = Keine Plotstiltabelle geladen. Laden oder erstellen Sie eine Tabelle. @@ -1031,22 +1031,22 @@ file = .oops-nothing-to-restore = OOPS: nichts zu restaurieren. .oops-restored-n-object-s = OOPS: Wiederherstellung von __ocs_fmt_0__ Objekt(en). .open-cad-studio-ready = Open CAD Studio ist bereit. - .open-discussions-on-github = Offene Diskussionen zu GitHub - .open-file-title-case = Offene Datei + .open-discussions-on-github = Öffne Diskussionen auf GitHub + .open-file-title-case = Öffne Datei .open-file-picker-title-case = Datei öffnen… .open-and-save = Öffnen und Speichern .open-playlist-on-youtube = Wiedergabeliste auf YouTube öffnen .opened-name-entity-count-entities = „__ocs_fmt_0__“ geöffnet — __ocs_fmt_1__ Objekte .opened-printer-properties = Geöffnete Druckereigenschaften. - .opened-read-only-against-other-editors-error = Read-only gegen andere Editoren geöffnet: __ocs_fmt_0__ + .opened-read-only-against-other-editors-error = Nur-Lese-Zugriff von anderem Bearbeiter geöffnet: __ocs_fmt_0__ .page-setup-applied-to-the-layout = Seiteneinrichtung auf das Layout angewendet. - .path-type = Art der Trassen: + .path-type = Art des Pfades: .plot-area-is-empty-pick-a-larger-window = Die Fläche ist leer. Wählen Sie ein größeres Fenster. - .plot-style-aci-aci-updated = Plotstyle ACI __ocs_fmt_0__ aktualisiert. - .plot-style-table-is-not-loaded = Die Plot-Tabelle '__ocs_fmt_0__' wird nicht geladen. + .plot-style-aci-aci-updated = Plotstil ACI __ocs_fmt_0__ aktualisiert. + .plot-style-table-is-not-loaded = Die Plotstil-Tabelle '__ocs_fmt_0__' wird nicht geladen. .quick-print-works-in-model-space = Schnelldruck ist nur im Modellbereich verfügbar. - .reload-failed-error = Reload fehlgeschlagen: __ocs_fmt_0__ - .saveall-save-each-tab-individually-in-the-web = SAVEALL: Speichern Sie jeden Tab einzeln im Web Build. + .reload-failed-error = Nachladen fehlgeschlagen: __ocs_fmt_0__ + .saveall-save-each-tab-individually-in-the-web = SAVEALL: Speichern Sie jeden Reiter einzeln im Web Build. .saveall-saved-saved-drawing-s = SAVEALL: gespeichert __ocs_fmt_0__ Zeichnung(en)__ocs_fmt_1__. .saveall-already-has-a-save-running = SAVEALL: Für __ocs_fmt_0__ wird bereits gespeichert .saveall-failed-e = SAVEALL: __ocs_fmt_0__ fehlgeschlagen: __ocs_fmt_1__ @@ -1062,15 +1062,15 @@ file = .selection-has-no-printable-area = Die Auswahl hat keinen bedruckbaren Bereich. .selection-has-no-printable-geometry = Die Auswahl hat keine druckbare Geometrie. .sending-to-system-printer = Senden an Systemdrucker... - .type-a-command-or-use-the-ribbon-open-obj-inse = Geben Sie einen Befehl ein oder verwenden Sie das Menüband. Öffnen Sie den Tab OBJ: INSERT. + .type-a-command-or-use-the-ribbon-open-obj-inse = Geben Sie einen Befehl ein oder verwenden Sie das Menüband. Öffnen Sie den Reiter OBJ: INSERT. .unable-to-acquire-edit-lock-error = Unfähig, edit lock zu erwerben: __ocs_fmt_0__ .unable-to-save-file-name-file-is-in-use-by-ano = Kann "__ocs_fmt_0__" nicht speichern: Die Datei wird von einer anderen Anwendung verwendet. .unable-to-save-error = Kann "__ocs_fmt_0__" nicht speichern: __ocs_fmt_1__ - .usage-script-path-to-scr-file = Verwendung: SCRIPT - .usage-undo-number-of-steps = Verwendung: UNDO [number of steps] + .usage-script-path-to-scr-file = Anwendung: SCRIPT + .usage-undo-number-of-steps = Anwendung: UNDO [number of steps] .used-for-the-first-save-of-a-new-drawing-exist = Wird für die erste Speicherung einer neuen Zeichnung verwendet. Bestehende Zeichnungen behalten ihren Dateityp und ihre Version bei. .visual-style-label = Visueller Stil: __ocs_fmt_0__. - .warning-corrupt-entities-dropped-parser-junk-b = Warnung: __ocs_fmt_0__ beschädigte Objekte verworfen (ungültige Parserdaten — fehlerhafte Normalen oder Anzahlen) + .warning-corrupt-entities-dropped-parser-junk-b = Warnung: __ocs_fmt_0__ beschädigte Objekte verworfen (ungültige Analysedaten — fehlerhafte Normalen oder Anzahlen) .warning-corrupt-xref-entities-dropped = Warnung: __ocs_fmt_0__ beschädigte XRef-Objekte verworfen .xref-loaded = XREF Geladen "__ocs_fmt_0__" .cmd-cannot-create-folder-e = __ocs_fmt_0__: kann keinen Ordner erstellen (__ocs_fmt_1__). @@ -1093,11 +1093,11 @@ inquiry = .addselected-selected-object-not-found = ADDSELECTED: ausgewähltes Objekt nicht gefunden. .add-selected-drawing-a-new-kind-on-layer-layer = Auswahl hinzufügen: Neues Objekt vom Typ __ocs_fmt_0__ auf Ebene „__ocs_fmt_1__“ zeichnen. .angle-angle = Winkel = __ocs_arg_angle__° - .angle-0-0000-degenerate-rays = Winkel = 0.0000° (entartete Strahlen) + .angle-0-0000-degenerate-rays = Winkel = 0.0000° (zerfallende Strahlen) .area-area-perimeter-perimeter = Fläche = __ocs_arg_area__, Umfang = __ocs_arg_perimeter__ .bedit-block-edit-discarded = BEDIT: Block "__ocs_fmt_0__" edit verworfen. .bedit-block-saved-all-references-updated = BEDIT: Block "__ocs_fmt_0__" gespeichert. Alle Referenzen werden aktualisiert. - .bedit-editing-block-use-save-block-or-discard = BEDIT: Bearbeiten des Blocks "__ocs_fmt_0__". Verwenden Sie Speicherblock oder Discard, um ihn zu beenden. + .bedit-editing-block-use-save-block-or-discard = BEDIT: Bearbeiten des Blocks "__ocs_fmt_0__". Verwenden Sie Block speichern oder verwerfen um ihn zu beenden. .bedit-block-not-found = BEDIT: Block "__ocs_fmt_0__" nicht gefunden. .bedit-cannot-edit-an-external-reference-xref = BEDIT: kann keine externe Referenz (xref) bearbeiten. .bedit-finish-the-active-refedit-refclose-first = BEDIT: Beenden Sie zuerst die aktive REFEDIT (REFCLOSE). @@ -1113,10 +1113,10 @@ inquiry = .dblist-drawing-has-no-entities = DBLIST: Zeichnung enthält keine Objekte. .dblist-count-entit = DBLIST: __ocs_fmt_0__ Objekt(e). .dist-specify-first-point = DIST Geben Sie den ersten Punkt an: - .dist-specify-second-point = DIST Zweiter Punkt angeben: + .dist-specify-second-point = DIST Zweiten Punkt angeben: .divide-enter-number-of-segments = DIVIDE Geben Sie die Anzahl der Segmente ein: .divide-select-object-to-divide = DIVIDE Objekt auswählen, um zu teilen: - .deselected = Ausgewählt. + .deselected = Abgewählt. .distance-dist-angle-in-xy-plane-angle-xy-delta = Abstand = __ocs_arg_dist__, Winkel in XY Ebene = __ocs_arg_angle_xy__° Delta X = __ocs_arg_dx__, Delta Y = __ocs_arg_dy__, Delta Z = __ocs_arg_dz__ @@ -1127,82 +1127,82 @@ inquiry = .find-not-found = FIND: "__ocs_fmt_0__" nicht gefunden. .find-specify-search-text = FIND: Suchtext angeben. .find-match-es-for = FIND: __ocs_fmt_0__ match(es) für "__ocs_fmt_1__". - .findall-text-to-find-add-replace-text-by-typin = FINDALL Text zu finden (REPLACE durch Eingabe hinzufügen): + .findall-text-to-find-add-replace-text-by-typin = FINDALL Text auffinden (REPLACE durch Eingabe hinzufügen): .flatten-no-entities = FLATTEN: Keine Objekte ausgewählt. .flatten-entity-ies-moved-to-z-0 = FLATTEN: __ocs_fmt_0__ Objekt(e) auf Z=0 verschoben. .id-specify-point = ID Anzugeben ist: - .list-no-entities-selected-select-entities-firs = LIST: keine Entitäten ausgewählt. + .list-no-entities-selected-select-entities-firs = LIST: keine Objekte ausgewählt. .massprop-no-entities-selected-select-entities = MASSPROP: Keine Objekte ausgewählt. .measure-select-object-to-measure = MEASURE Objekt auswählen, um zu messen: .measure-specify-segment-length = MEASURE Segmentlänge angeben: - .measuregeom-enter-an-option-distance-radius-an = MEASUREGEOM Geben Sie eine Option ein [Abstand/Radius/Angle/ARea]: + .measuregeom-enter-an-option-distance-radius-an = MEASUREGEOM Geben Sie eine Option ein [Distance/Radius/Angle/ARea]: .measuregeom-select-arc-or-circle = MEASUREGEOM Bogen oder Kreis auswählen: .measuregeom-specify-first-corner-point-enter-t = MEASUREGEOM Ersten Eckpunkt angeben (Eingabe zum Abbrechen): .measuregeom-specify-first-point = MEASUREGEOM Geben Sie den ersten Punkt an: .measuregeom-specify-first-ray-point = MEASUREGEOM Erststrahlpunkt angeben: .measuregeom-specify-next-point-n-picked-enter = MEASUREGEOM Nächster Punkt angeben (__ocs_arg_n__ ausgewählt, Geben Sie ein, um zu berechnen): - .measuregeom-specify-second-point = MEASUREGEOM Zweiter Punkt angeben: - .measuregeom-specify-second-ray-point = MEASUREGEOM Zweiter Strahlpunkt angeben: - .measuregeom-specify-vertex-point = MEASUREGEOM Scheitelpunkt-Punkt angeben: + .measuregeom-specify-second-point = MEASUREGEOM Zweiten Punkt angeben: + .measuregeom-specify-second-ray-point = MEASUREGEOM Zweiten Strahlpunkt angeben: + .measuregeom-specify-vertex-point = MEASUREGEOM Stützpunkt angeben: .measure-geometry = Geometrie der Messwerte - .qselect-no-matching-entities = QSELECT: keine passenden Entitäten. + .qselect-no-matching-entities = QSELECT: keine passenden Objekte. .qselect-entity-ies-selected = QSELECT: __ocs_fmt_0__ Objekt(e) ausgewählt. .refclose-block-saved-all-references-updated = REFCLOSE: Block "__ocs_fmt_0__" gespeichert. Alle Referenzen werden aktualisiert. .refclose-changes-discarded = REFCLOSE: Änderungen verworfen. .refclose-no-refedit-session-active = REFCLOSE: keine REFEDIT Sitzung aktiv. - .refedit-editing-block-run-refclose-to-save-ref = REFEDIT: Bearbeiten Block "__ocs_fmt_0__". Laufen Sie REFCLOSE zu speichern, REFCLOSE_DISCARD zu annullieren. - .refedit-a-session-is-already-active-use-refclo = REFEDIT: Eine Sitzung ist bereits aktiv. Verwenden Sie REFCLOSE zuerst. + .refedit-editing-block-run-refclose-to-save-ref = REFEDIT: Block "__ocs_fmt_0__" earbeiten. REFCLOSE aufrufen zum speichern, REFCLOSE_DISCARD zum verwerfen. + .refedit-a-session-is-already-active-use-refclo = REFEDIT: Eine Sitzung ist bereits aktiv. Verwenden Sie zuerst REFCLOSE. .refedit-block-not-found = REFEDIT: Block "__ocs_fmt_0__" nicht gefunden. .refedit-block-is-empty = REFEDIT: Block ist leer. .refedit-selected-object-is-not-an-insert = REFEDIT: Ausgewähltes Objekt ist kein INSERT. .radius-radius-diameter-diameter = Radius = __ocs_arg_radius__, Durchmesser = __ocs_arg_diameter__ .selectall-object-s-selected = SELECTALL: __ocs_fmt_0__ Objekt(e) ausgewählt. - .selhandles-no-entities-selected-select-entitie = SELHANDLES: keine Entitäten ausgewählt. - .usage-cal-expression-e-g-cal-2-3-4 = Verwendung: CAL e.g. CAL (2+3)*4 - .usage-qselect-type-layer-color-linetype-value = Verwendung: QSELECT TYPE|LAYER|COLOR|LINETYPE + .selhandles-no-entities-selected-select-entitie = SELHANDLES: keine Objekte ausgewählt. + .usage-cal-expression-e-g-cal-2-3-4 = Anwendung: CAL e.g. CAL (2+3)*4 + .usage-qselect-type-layer-color-linetype-value = Anwendung: QSELECT TYPE|LAYER|COLOR|LINETYPE .x-x-y-y-z-z = X = __ocs_arg_x__, Y = __ocs_arg_y__, Z = __ocs_arg_z__ .type-name-handle-x-layer-color-lt = __ocs_fmt_0__ Griff:__ocs_fmt_1__ Ebene:__ocs_fmt_2__ Farbe:__ocs_fmt_3__ LT:__ocs_fmt_4____ocs_fmt_5__ - .area-4-perimeter-4-centroid-4-4 = __ocs_fmt_0__ Fläche=__ocs_fmt_1__ Perimeter=__ocs_fmt_2__ Centroid=(__ocs_fmt_3__,__ocs_fmt_4__) + .area-4-perimeter-4-centroid-4-4 = __ocs_fmt_0__ Fläche=__ocs_fmt_1__ Umfang=__ocs_fmt_2__ Schwerpunkt=(__ocs_fmt_3__,__ocs_fmt_4__) draw = .insert-x-no-attributes = __ocs_fmt_0__ einfügen: keine Attribute. .tag-val = [__ocs_fmt_0__] = __ocs_fmt_1__ .no-closed-boundary-found = ⚠ Keine geschlossene Begrenzung gefunden. - .inverted = , invertiert + .inverted = , vertauscht .value-2-point = 2-Punkt - .value-2d-solid = 2D Festkörper + .value-2d-solid = 2D Volumenkörper .value-3-point = 3-Punkt - .value-3d-polyline = 3D-Polylin - .value-3dmirror-mirror-plane-x-y-z = 3DMIRROR Spiegelebene [X/Y/Z]: + .value-3d-polyline = 3D-Polylinie + .value-3dmirror-mirror-plane-x-y-z = 3DMIRROR Spiegelebene [X/Y/Z]: .value-3dpoly-specify-next-point-close-undo = 3DPOLY Nächsten Punkt angeben [Schließen/Zurück]: .value-3dpoly-specify-next-point-undo = 3DPOLY Nächsten Punkt angeben [Zurück]: .value-3dpoly-specify-start-point-of-polyline = 3DPOLY Beginn der Polylinie angeben: - .value-3drotate-angle-in-degrees = 3DROTATE Winkel in Grad: - .value-3drotate-rotation-axis-x-y-z = 3DROTATE Drehachse [X/Y/Z]: + .value-3drotate-angle-in-degrees = 3DROTATE Winkel in Grad: + .value-3drotate-rotation-axis-x-y-z = 3DROTATE Drehachse [X/Y/Z]: .arc-specify-center = ARC Zentrum angeben: .arc-specify-end-point-c-cx-cy-r-r = ARC Endpunkt angeben [c=(__ocs_arg_cx__,__ocs_arg_cy__) r=__ocs_arg_r__]: .arc-specify-start-point = ARC Startpunkt angeben: .arc-3p-specify-end-point = ARC 3P Endpunkt angeben: - .arc-3p-specify-second-point-on-arc = ARC 3P Zweiter Punkt auf dem Bogen angeben: + .arc-3p-specify-second-point-on-arc = ARC 3P Zweiten Punkt auf dem Bogen angeben: .arc-3p-specify-start-point = ARC 3P Startpunkt angeben: - .arc-csa-click-end-direction-or-type-arc-span-i = ARC CSA Klicken Sie auf die Endrichtung oder Typ Bogenspanne in Grad [start=__ocs_arg_sa__]: - .arc-csa-specify-center = ARC CSA Zentrum angeben: + .arc-csa-click-end-direction-or-type-arc-span-i = ARC CSA Klicken Sie auf die Endrichtung oder Typ Bogen in Grad [start=__ocs_arg_sa__]: + .arc-csa-specify-center = ARC CSA Mittelpunkt angeben: .arc-csa-specify-start-point = ARC CSA Startpunkt angeben: - .arc-csl-click-chord-end-or-type-chord-length-r = ARC CSL Akkordende oder Akkordlänge eingeben [r=__ocs_arg_r__]: - .arc-csl-specify-center = ARC CSL Zentrum angeben: + .arc-csl-click-chord-end-or-type-chord-length-r = ARC CSL Sehnenende oder Sehnenlänge eingeben [r=__ocs_arg_r__]: + .arc-csl-specify-center = ARC CSL Mittelpunkt angeben: .arc-csl-specify-start-point = ARC CSL Startpunkt angeben: - .arc-continue-no-previous-line-or-arc-to-contin = ARC Fortfahren Keine vorherige Linie oder Bogen, um fortzufahren. - .arc-continue-specify-end-point-ctrl-flip-direc = ARC Weiter Endpunkt angeben [Strg = Flip-Richtung]: - .arc-sca-click-end-direction-or-type-arc-span-i = ARC SCA Klicken Sie auf die Endrichtung oder Typ Bogenspanne in Grad [start=__ocs_arg_sa__]: - .arc-sca-specify-center = ARC SCA Zentrum angeben: + .arc-continue-no-previous-line-or-arc-to-contin = ARC Fortfahren, keine vorherige Linie oder Bogen, um fortzufahren. + .arc-continue-specify-end-point-ctrl-flip-direc = ARC Forfahren, Endpunkt angeben [Strg = Flip-Richtung]: + .arc-sca-click-end-direction-or-type-arc-span-i = ARC SCA Klicken Sie auf die Endrichtung oder Typ Bogen in Grad [start=__ocs_arg_sa__]: + .arc-sca-specify-center = ARC SCA Mittelpunkt angeben: .arc-sca-specify-start-point = ARC SCA Startpunkt angeben: - .arc-sce-specify-center = ARC SCE Zentrum angeben: + .arc-sce-specify-center = ARC SCE Mittelpunkt angeben: .arc-sce-specify-end-point-r-r = ARC SCE Endpunkt angeben [r=__ocs_arg_r__]: .arc-sce-specify-start-point = ARC SCE Startpunkt angeben: - .arc-scl-click-chord-end-or-type-chord-length-r = ARC SCL Akkordende oder Akkordlänge eingeben [r=__ocs_arg_r__]: - .arc-scl-specify-center = ARC SCL Zentrum angeben: + .arc-scl-click-chord-end-or-type-chord-length-r = ARC SCL Sehnenende oder Sehnenlänge eingeben [r=__ocs_arg_r__]: + .arc-scl-specify-center = ARC SCL Mittelpunkt angeben: .arc-scl-specify-start-point = ARC SCL Startpunkt angeben: - .arc-sea-specify-angle-move-cursor-perpendicula = ARC SEA Winkel angeben (Cursor senkrecht zum Akkord verschieben): + .arc-sea-specify-angle-move-cursor-perpendicula = ARC SEA Winkel angeben (Eingabezeiger senkrecht zum Bezug verschieben): .arc-sea-specify-end-point = ARC SEA Endpunkt angeben: .arc-sea-specify-start-point = ARC SEA Startpunkt angeben: .arc-sed-specify-end-point = ARC SED Endpunkt angeben: @@ -1219,7 +1219,7 @@ draw = .attdef-specify-insertion-point-for-tag = ATTDEF Einfügungspunkt für '__ocs_arg_tag__' angeben: .attdisp-attribute-display-on-off-normal = ATTDISP Attributanzeige [Ein/Aus/Normal]: .attdisp-sub-count-attribute-definition-s-updat = ATTDISP __ocs_fmt_0__: __ocs_fmt_1__ Attributdefinition(en) aktualisiert. - .attedit-no-insert-entities-in-selection = ATTEDIT: keine Entitäten in die Auswahl einfügen. + .attedit-no-insert-entities-in-selection = ATTEDIT: keine Objekte in die Auswahl einfügen. .attedit-select-an-insert-entity-first = ATTEDIT: Zuerst eine Blockreferenz auswählen. .attedit-tag-tag-up-not-found-in-selection = ATTEDIT: tag '__ocs_fmt_0__' nicht in der Auswahl gefunden. .attedit-updated-changed-attribute-s-tag-up-new = ATTEDIT: aktualisiertes __ocs_fmt_0__ Attribut(s) [__ocs_fmt_1__] = __ocs_fmt_2__. @@ -1246,8 +1246,8 @@ draw = .circle-ttt-select-second-tangent-object = CIRCLE TTT Wähle das zweite Tangentenobjekt aus: .circle-ttt-select-third-tangent-object = CIRCLE TTT Wähle drittes Tangentenobjekt: .copy-specify-base-point-count-objects = COPY Basispunkt angeben [__ocs_arg_count__ Objekte]: - .center-line = Zentrumlinie - .center-mark = Zentrum Mark + .center-line = Zentrum, Linie + .center-mark = Zentrum, Marker .center-axes = Zentrum, Achsen .center-diameter = Mitte, Durchmesser .center-radius = Mitte, Radius @@ -1261,14 +1261,14 @@ draw = .donut-specify-center-of-donut-enter-to-exit = DONUT Mittelpunkt des Rings angeben (Eingabe zum Beenden): .donut-specify-inside-diameter-0 = DONUT Innendurchmesser <0> angeben: .donut-specify-outside-diameter = DONUT Außendurchmesser angeben: - .draw-manually = Handzeichen - .ellipse-specify-center = ELLIPSE Zentrum angeben: - .ellipse-specify-major-axis-endpoint = ELLIPSE Endpunkt der Hauptachse angeben: - .ellipse-specify-minor-axis-point-or-type-half = ELLIPSE Kleiner Achsenpunkt oder Typhalblänge angeben [Hauptachse = __ocs_fmt_0__]: + .draw-manually = Manuell zeichnen + .ellipse-specify-center = ELLIPSE Mittelpunkt angeben: + .ellipse-specify-major-axis-endpoint = ELLIPSE Endpunkt der Hauptachse angeben: + .ellipse-specify-minor-axis-point-or-type-half = ELLIPSE Kleiner Achsenpunkt oder Typhalblänge angeben [Hauptachse = __ocs_fmt_0__]: .ellipse-axis-specify-first-endpoint-of-major-a = ELLIPSE (Achse) Geben Sie den ersten Endpunkt der Hauptachse an: - .ellipse-axis-specify-minor-axis-point-or-type = ELLIPSE (Achse) Kleinerer Achsenpunkt oder Typhalblänge angeben [großes r=__ocs_fmt_0__]: + .ellipse-axis-specify-minor-axis-point-or-type = ELLIPSE (Achse) Kleineren Achsenpunkt oder Typhalblänge angeben [großes r=__ocs_fmt_0__]: .ellipse-axis-specify-second-endpoint-of-major = ELLIPSE (Achse) Geben Sie den zweiten Endpunkt der Hauptachse an: - .ellipse-arc-specify-center = ELLIPSE ARC Zentrum angeben: + .ellipse-arc-specify-center = ELLIPSE ARC Mittelpunkt angeben: .ellipse-arc-specify-end-angle-point-or-type-de = ELLIPSE ARC Endwinkelpunkt oder Typgrad angeben [start=__ocs_arg_sa__]: .ellipse-arc-specify-major-axis-endpoint = ELLIPSE ARC Endpunkt der Hauptachse angeben: .ellipse-arc-specify-minor-axis-point-or-type-h = ELLIPSE ARC Kleinachsenpunkt oder Typhalblänge angeben [Hauptachse r=__ocs_arg_r__]: @@ -1291,18 +1291,18 @@ draw = .helix-specify-centre-point-of-base = HELIX Geben Sie den Mittelpunkt der Basis an: .helix-specify-helix-height = HELIX Helixhöhe angeben: .helix-specify-top-radius-same-as-base = HELIX Geben Sie den oberen Radius an: - .image-specify-insertion-point-name = IMAGE Einführstelle angeben (__ocs_arg_name__): + .image-specify-insertion-point-name = IMAGE Einfügepunkt angeben (__ocs_arg_name__): .image-specify-width-drag-right = IMAGE Breite angeben (rechts abziehen): - .invert-off = Invertiert: ausgeschaltet - .invert-on = Invertieren: am + .invert-off = Invertiert: aus + .invert-on = Invertieren: an .line-specify-first-point = LINE Geben Sie den ersten Punkt an: .line-specify-next-point-close-undo = LINE Nächsten Punkt angeben [Schließen/Zurück]: .line-specify-next-point-undo = LINE Nächsten Punkt angeben [Zurück]: .line-color-aci = Linienfarbe (ACI): .line-type = Linientyp: - .line-weight = Streckengewicht: + .line-weight = Liniengewicht: .linear-factor-dimlfac = Linearer Faktor (DIMLFAC) - .literal-spaces-space-stays-in-the-line-instead = Wörtlich definierte Leerzeichen: Leerzeichen bleiben in der Zeile, anstatt den Befehl auszuführen (das gleiche wie das Eingeben eines führenden '>'). Bleibt ein, bis man abschaltet. + .literal-spaces-space-stays-in-the-line-instead = Wörtlich definierte Leerzeichen: Leerzeichen verbleiben in der Zeile, anstatt den Befehl auszuführen (das gleiche wie das Eingeben eines führenden '>'). Aktiv bis zur Abschaltung. .mline-enter-scale-factor = MLINE Skalierungsfaktor eingeben: .mline-specify-next-point-count-pts-enter-to-fi = MLINE Nächsten Punkt angeben (__ocs_arg_count__ Punkte, Eingabe zum Beenden, C zum Schließen, S zum Festlegen des Maßstabs): .mline-specify-start-point-scale-scale = MLINE Startpunkt angeben (scale=__ocs_arg_scale__): @@ -1311,10 +1311,10 @@ draw = .point-specify-point = POINT Anzugeben ist: .polygon-enter-number-of-sides = POLYGON Geben Sie die Anzahl der Seiten <__ocs_fmt_0__> ein: .polygon-specify-center-sides = POLYGON Mitte angeben [__ocs_fmt_0__ Seiten]: - .polygon-specify-vertex-on-circle-sides-inscrib = POLYGON Scheitelpunkt im Kreis angeben [__ocs_fmt_0__ Seiten eingeschrieben]: - .polygon-c-enter-number-of-sides = POLYGON C Geben Sie die Anzahl der Seiten <__ocs_fmt_0__> ein: - .polygon-c-specify-center-sides = POLYGON C Mitte angeben [__ocs_fmt_0__ Seiten]: - .polygon-c-specify-edge-midpoint-radius-sides-c = POLYGON C Kanten-Mittelpunkt-Radius angeben [__ocs_fmt_0__ Seiten umschrieben]: + .polygon-specify-vertex-on-circle-sides-inscrib = POLYGON Stützpunkt im Kreis angeben [__ocs_fmt_0__ Seiten eingeschrieben]: + .polygon-c-enter-number-of-sides = POLYGON C Geben Sie die Anzahl der Seiten <__ocs_fmt_0__> ein: + .polygon-c-specify-center-sides = POLYGON C Mitte angeben [__ocs_fmt_0__ Seiten]: + .polygon-c-specify-edge-midpoint-radius-sides-c = POLYGON C Kanten-Mittelpunkt-Radius angeben [__ocs_fmt_0__ Seiten umschrieben]: .polygon-e-enter-number-of-sides-n = POLYGON E Geben Sie die Anzahl der Seiten ein <__ocs_arg_n__>: .polygon-e-specify-first-endpoint-of-edge-n-sid = POLYGON E Geben Sie den ersten Endpunkt der Kante an [__ocs_arg_n__ Seiten]: .polygon-e-specify-second-endpoint-of-edge-n-si = POLYGON E Geben Sie den zweiten Endpunkt der Kante an [__ocs_arg_n__ Seiten]: @@ -1322,9 +1322,9 @@ draw = .polysolid-wall-width = POLYSOLID Wandbreite: .pyramid-base-radius = PYRAMID Grundradius: .pyramid-height-add-sides-by-typing-a-3rd-numbe = PYRAMID Höhe (Seiten hinzufügen durch Eingabe einer 3. Zahl): - .polygon-circumscribed = Polygon – umschrieben + .polygon-circumscribed = Polygon – Umschreibung .polygon-edge = Polygon - Rand - .polygon-inscribed = Polygon - Inschrift + .polygon-inscribed = Polygon - Vieleck .ray-specify-start-point = RAY Startpunkt angeben: .ray-specify-through-point = RAY Geben Sie durch Punkt an: .rect-specify-first-corner = RECT Geben Sie die erste Ecke an: @@ -1355,7 +1355,7 @@ draw = .revcloud-polygonal-start-point = REVCLOUD Polygonaler Startpunkt (__ocs_arg_style__, Bogenlänge __ocs_arg_length__): __ocs_arg_message__ .revcloud-next-polygonal-point = REVCLOUD Nächster Polygonpunkt (__ocs_arg_count__ Punkte, Enter zum Schließen): __ocs_arg_message__ .revcloud-freehand-first-point = REVCLOUD Erster Freihandpunkt (__ocs_arg_style__, Bogenlänge __ocs_arg_length__): __ocs_arg_message__ - .revcloud-guide-cursor = REVCLOUD Cursor führen, dann klicken oder Enter zum Schließen drücken: __ocs_arg_message__ + .revcloud-guide-cursor = REVCLOUD Eingabezeiger führen, dann klicken oder Eingabe zum Schließen drücken: __ocs_arg_message__ .revcloud-approximate-arc-chord-length = REVCLOUD Ungefähre Bogensehnenlänge <__ocs_arg_length__>: .revcloud-style = REVCLOUD Stil [Normal/Kalligrafie] <__ocs_arg_style__>: .revcloud-select-closed-curve = REVCLOUD Geschlossenen Kreis, Ellipse, Polylinie oder Spline auswählen: __ocs_arg_message__ @@ -1365,9 +1365,9 @@ draw = .revcloud-pick-side = REVCLOUD Zu löschende Seite der ursprünglichen Wolke wählen: .revcloud-path-cannot-form = Der ausgewählte Pfad kann keine Revisionswolke bilden. .revcloud-select-closed-revision-cloud = Eine geschlossene Revisionswolken-Polylinie auswählen. - .revcloud-polyline-not-planar = Die ausgewählte Polylinie ist nicht planar. - .revcloud-corners-non-zero-area = Die beiden Ecken müssen eine Fläche ungleich null definieren. - .revcloud-arc-length-positive = Die Bogenlänge muss größer als null sein. + .revcloud-polyline-not-planar = Die ausgewählte Polylinie ist nicht eben. + .revcloud-corners-non-zero-area = Die beiden Ecken müssen eine Fläche ungleich Null ergeben. + .revcloud-arc-length-positive = Die Bogenlänge muss größer als Null sein. .revcloud-enter-valid-arc-length = Eine gültige Bogenlänge eingeben. .revcloud-select-supported-closed-curve = Eine unterstützte geschlossene Kurve auswählen. .revcloud-select-closed-entity = Einen geschlossenen Kreis, eine Ellipse, Polylinie oder einen Spline auswählen. @@ -1379,12 +1379,12 @@ draw = .rectangle-rotated = Rechteck - rotiert .rectangle-two-corners = Rechteck - Zwei Ecken .rev-cloud = Rev. Cloud - .section-cutting-plane-axis-x-y-z = SECTION Schnittebenenachse [X/Y/Z]: + .section-cutting-plane-axis-x-y-z = SECTION Schnittebenenachse [X/Y/Z]: .section-offset-along-x = SECTION versetzt entlang X: .section-offset-along-y = SECTION längs Y versetzt: .section-offset-along-z = SECTION Versetzt entlang Z: - .sketch-pen-down-move-to-sketch-click-to-lift-e = SKETCH Pen down — zum Skizzieren bewegen, zum Liften klicken, zum Aufzeichnen eingeben: - .sketch-pen-up-click-to-lower-the-pen-enter-to = SKETCH Pen up — klicken Sie auf den Stift zu senken, geben Sie ein, um aufzuzeichnen: + .sketch-pen-down-move-to-sketch-click-to-lift-e = SKETCH Stift unten — zum Skizzieren bewegen, zum anheben klicken, Eingabe zum Aufzeichnen: + .sketch-pen-up-click-to-lower-the-pen-enter-to = SKETCH Stift oben — klicken Sie auf den Stift zum absenken, Eingabe um aufzuzeichnen: .sketch-pen = Stift .sketch-increment = Schrittweite .sketch-record = Aufzeichnen @@ -1426,17 +1426,17 @@ draw = .tan-tan-tan = Tangente, Tangente, Tangente .trace = Band .ttt = Ttt - .usage-attdisp-on-off-normal = Verwendung: ATTDISP ON | OFF | NORMAL - .usage-polysolid-width-height-select-a-polyline = Verwendung: POLYSOLID (select a polyline first) - .usage-pyramid-radius-height-sides-default-4-si = Verwendung: PYRAMID [sides] (default 4 sides) + .usage-attdisp-on-off-normal = Anwendung: ATTDISP ON | OFF | NORMAL + .usage-polysolid-width-height-select-a-polyline = Anwendung: POLYSOLID (select a polyline first) + .usage-pyramid-radius-height-sides-default-4-si = Anwendung: PYRAMID [sides] (default 4 sides) .wipeout-specify-first-point-or-polyline = WIPEOUT Geben Sie den ersten Punkt oder [Rahmen/Polylinie] an : .wipeout-specify-next-point-or-undo-close-n-poi = WIPEOUT Nächsten Punkt angeben oder [Zurück/Schließen] (__ocs_arg_n__ Punkte): - .wipeout-polyline-select-a-closed-planar-polyli = WIPEOUT Polylinie Wählen Sie eine geschlossene planare Polylinie: + .wipeout-polyline-select-a-closed-planar-polyli = WIPEOUT Wählen Sie eine geschlossene planare Polylinie: .wipeout-rectangular-specify-first-corner = WIPEOUT Rechteckig Geben Sie die erste Ecke an: .wipeout-rectangular-specify-opposite-corner = WIPEOUT Rechteckig Angeben der gegenüberliegenden Ecke: - .wipeout-frames-enter-frame-mode = WIPEOUT Rahmen Rahmenmodus eingeben [Aus/Ein/AnzeigenAberNichtPlotten] <__ocs_arg_mode__>: - .wipeout-polyline-erase-source = WIPEOUT Polylinie Quellpolylinie löschen? [Ja/Nein] : - .wipeout-polyline-invalid = WIPEOUT Polylinie: Wählen Sie eine gerade, geschlossene, planare 2D-Polylinie mit mindestens 3 sich nicht schneidenden Scheitelpunkten. + .wipeout-frames-enter-frame-mode = WIPEOUT Modus des Rahmens eingeben [Aus/Ein/AnzeigenAberNichtPlotten] <__ocs_arg_mode__>: + .wipeout-polyline-erase-source = WIPEOUT Quellpolylinie löschen? [Ja/Nein] : + .wipeout-polyline-invalid = WIPEOUT Wählen Sie eine gerade, geschlossene, planare 2D-Polylinie mit mindestens 3 sich nicht schneidenden Stützpunkten. .setvar-frame-range = SETVAR: __ocs_fmt_0__ erfordert 0, 1 oder 2. .frames = Rahmen .display-but-not-plot = Anzeigen, aber nicht plotten @@ -1444,7 +1444,7 @@ draw = .image-adjust = Bildanpassung .x-ray = Röntgenstrahlen .xline-specify-a-point = XLINE Geben Sie einen Punkt an: - .xline-specify-through-point = XLINE Geben Sie durch Punkt an: + .xline-specify-through-point = XLINE Geben Sie den Durchgangspunkt an: .n-object-s-erased = __ocs_fmt_0__ Objekt(e) gelöscht. modify = From 9fdc920234cabaae2a4c4ca8020fc39c721a9eb1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:10:28 +0300 Subject: [PATCH 22/98] Use consistent dimension data revision --- Cargo.lock | 2 +- Cargo.toml | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 189c1b22..b8de184b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd" +source = "git+https://github.com/ramox81/cadcodec.git?rev=e393063#e39306362e5022a574b27acfea32a2af1cf0e574" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4cc5ae75..5046ed38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "e393063", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "76de3b3", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -60,6 +60,9 @@ ashpd = { version = "0.13.13", default-features = false, features = ["async-io", iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } +[patch."https://github.com/HakanSeven12/cadcodec.git"] +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "e393063" } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } meshopt = "0.6.2" From a30010cb3e89e1da3eaf8ddade32c5b28388341d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:10:29 +0300 Subject: [PATCH 23/98] Keep linear dimensions associated with sources --- src/app/command_driver.rs | 53 ++++++++- src/command.rs | 5 + src/scene/dimension_assoc.rs | 204 +++++++++++++++++++++++++++++++++++ src/scene/mod.rs | 6 ++ 4 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 src/scene/dimension_assoc.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 97a03e26..77a1e175 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -801,7 +801,21 @@ impl OpenCADStudio { // the structure snapshot fallback. let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - self.commit_entity(entity); + let is_linear_dimension = matches!( + entity, + acadrust::EntityType::Dimension(acadrust::entities::Dimension::Linear(_)) + ); + let committed = self.commit_entity_handle(entity); + if is_linear_dimension { + if let Some(handle) = committed { + let sources = self.tabs[i] + .scene + .infer_linear_dimension_sources(handle); + self.tabs[i] + .scene + .attach_linear_dimension_association(handle, sources); + } + } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -1191,7 +1205,21 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "ENTITY"); let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - self.commit_entity(entity); + let is_linear_dimension = matches!( + entity, + acadrust::EntityType::Dimension(acadrust::entities::Dimension::Linear(_)) + ); + let committed = self.commit_entity_handle(entity); + if is_linear_dimension { + if let Some(handle) = committed { + let sources = self.tabs[i] + .scene + .infer_linear_dimension_sources(handle); + self.tabs[i] + .scene + .attach_linear_dimension_association(handle, sources); + } + } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -1201,6 +1229,27 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } + CmdResult::CommitAssociativeDimension { entity, source } => { + let label = self.history_label_from_active_cmd(i, "DIMLINEAR"); + let pending = self.begin_undo(i, label, 1, false); + if let Some(handle) = self.commit_entity_handle(entity) { + self.tabs[i] + .scene + .attach_linear_dimension_association(handle, [Some(source), Some(source)]); + self.tabs[i].scene.bump_entities(&[ + (handle, crate::scene::ChangeKind::Modified), + (source, crate::scene::ChangeKind::Modified), + ]); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } CmdResult::CommitSolid { entity, solid, diff --git a/src/command.rs b/src/command.rs index 0ed4a77f..89b8d038 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1212,6 +1212,11 @@ pub enum CmdResult { CommitEntitiesAndExit(Vec), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), + /// Commit an object-selected linear dimension and retain its source link. + CommitAssociativeDimension { + entity: EntityType, + source: Handle, + }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded /// rendering). Ends the command. diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs new file mode 100644 index 00000000..ea91679f --- /dev/null +++ b/src/scene/dimension_assoc.rs @@ -0,0 +1,204 @@ +use acadrust::entities::Dimension; +use acadrust::objects::{ + AssocDimensionAssociation, AssocDimensionReference, AssociativeData, + AssociativeObject, ObjectType, +}; +use acadrust::types::{Handle, Vector3}; +use acadrust::EntityType; + +use super::{ChangeKind, Scene}; + +fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { + let dx = first.x - second.x; + let dy = first.y - second.y; + let dz = first.z - second.z; + dx * dx + dy * dy + dz * dz +} + +fn source_points(entity: &EntityType) -> Vec { + match entity { + EntityType::Line(line) => vec![line.start, line.end], + EntityType::Arc(arc) => vec![arc.start_point(), arc.end_point()], + EntityType::LwPolyline(polyline) => polyline + .vertices + .iter() + .map(|vertex| { + Vector3::new(vertex.location.x, vertex.location.y, polyline.elevation) + }) + .collect(), + EntityType::Polyline2D(polyline) => polyline + .vertices + .iter() + .map(|vertex| vertex.location) + .collect(), + _ => Vec::new(), + } +} + +fn source_marker(entity: &EntityType, point: Vector3) -> Option { + source_points(entity) + .into_iter() + .enumerate() + .min_by(|(_, first), (_, second)| { + point_distance_squared(*first, point) + .total_cmp(&point_distance_squared(*second, point)) + }) + .map(|(index, _)| index as i32) +} + +fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { + let source = *reference.xrefs.first()?; + let entity = scene.document.get_entity(source)?; + source_points(entity) + .get(reference.main_gs_marker.max(0) as usize) + .copied() +} + +impl Scene { + pub(crate) fn attach_linear_dimension_association( + &mut self, + dimension: Handle, + sources: [Option; 2], + ) { + let Some(EntityType::Dimension(Dimension::Linear(linear))) = + self.document.get_entity(dimension) + else { + return; + }; + let first_point = linear.first_point; + let second_point = linear.second_point; + let source_data = [first_point, second_point].map(|point| point); + let resolved: [Option<(Handle, i32)>; 2] = std::array::from_fn(|index| { + let source = sources[index]?; + let entity = self.document.get_entity(source)?; + source_marker(entity, source_data[index]).map(|marker| (source, marker)) + }); + if resolved.iter().all(Option::is_none) { + return; + } + + let reference = |source: Handle, marker: i32, point: Vector3| AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 1, + xrefs: vec![source], + main_subent_type: 1, + main_gs_marker: marker, + osnap_point: point, + ..AssocDimensionReference::default() + }; + let mut references: [Vec; 4] = + std::array::from_fn(|_| Vec::new()); + let mut associativity = 0; + for (index, resolved) in resolved.into_iter().enumerate() { + if let Some((source, marker)) = resolved { + associativity |= 1 << index; + references[index].push(reference(source, marker, source_data[index])); + } + } + + let association_handle = self.document.allocate_handle(); + let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); + object.handle = association_handle; + object.reactors.push(dimension); + object.data = AssociativeData::DimensionAssociation(AssocDimensionAssociation { + associativity, + dimension, + references, + ..AssocDimensionAssociation::default() + }); + self.document + .objects + .insert(association_handle, ObjectType::Associative(object)); + + let mut reactor_targets = vec![dimension]; + reactor_targets.extend(sources.into_iter().flatten()); + reactor_targets.sort_by_key(|handle| handle.value()); + reactor_targets.dedup(); + for handle in reactor_targets { + if let Some(entity) = self.document.get_entity_mut(handle) { + if !entity.common().reactors.contains(&association_handle) { + entity.common_mut().reactors.push(association_handle); + } + } + } + } + + pub(crate) fn infer_linear_dimension_sources( + &self, + dimension: Handle, + ) -> [Option; 2] { + let Some(EntityType::Dimension(Dimension::Linear(linear))) = + self.document.get_entity(dimension) + else { + return [None, None]; + }; + [linear.first_point, linear.second_point].map(|point| { + self.document + .entities() + .filter(|entity| entity.common().handle != dimension) + .filter_map(|entity| { + source_points(entity) + .into_iter() + .map(|candidate| point_distance_squared(candidate, point)) + .min_by(f64::total_cmp) + .map(|distance| (distance, entity.common().handle)) + }) + .filter(|(distance, _)| *distance <= 1e-16) + .min_by(|first, second| first.0.total_cmp(&second.0)) + .map(|(_, handle)| handle) + }) + } + + pub(crate) fn refresh_associative_dimensions( + &mut self, + changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + let changed: rustc_hash::FxHashSet<_> = + changes.iter().map(|(handle, _)| *handle).collect(); + if changed.is_empty() { + return Vec::new(); + } + let associations: Vec<_> = self + .document + .objects + .values() + .filter_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + association + .references + .iter() + .flatten() + .any(|reference| reference.xrefs.iter().any(|handle| changed.contains(handle))) + .then_some(association.clone()) + }) + .collect(); + + let mut refreshed = Vec::new(); + for association in associations { + let first = association.references[0] + .first() + .and_then(|reference| resolve_reference(self, reference)); + let second = association.references[1] + .first() + .and_then(|reference| resolve_reference(self, reference)); + let (Some(first), Some(second)) = (first, second) else { + continue; + }; + if let Some(EntityType::Dimension(Dimension::Linear(linear))) = + self.document.get_entity_mut(association.dimension) + { + linear.first_point = first; + linear.second_point = second; + linear.base.actual_measurement = linear.measurement(); + linear.base.definition_point = linear.definition_point; + refreshed.push((association.dimension, ChangeKind::Modified)); + } + } + refreshed + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index b22dde38..cf14edc6 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -22,6 +22,7 @@ pub mod view; mod boundary; mod camera_ops; pub(crate) mod centerline; +pub(crate) mod dimension_assoc; pub(crate) mod centermark; mod entity; mod group_layer; @@ -2400,6 +2401,11 @@ impl Scene { changes.push(change); } } + for change in self.refresh_associative_dimensions(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } for change in self.refresh_associative_hatches(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); From f556a2b027e188a3265e745a301017847c439ba0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:10:29 +0300 Subject: [PATCH 24/98] Complete the linear dimension workflow --- src/modules/annotate/linear_dim.rs | 228 +++++++++++++++++++++++++++-- 1 file changed, 215 insertions(+), 13 deletions(-) diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index f2af91e0..4429b992 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -57,6 +57,14 @@ enum Step { DimensionLine { first: DVec3, second: DVec3 }, } +#[derive(Clone, Copy)] +enum AxisMode { + Automatic, + Horizontal, + Vertical, + Rotated(f64), +} + pub struct LinearDimensionCommand { step: Step, plane: WorkingPlane, @@ -68,6 +76,13 @@ pub struct LinearDimensionCommand { text_angle: Option, /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + /// True while a rotation value for the Rotated option is being entered. + awaiting_rotation: bool, + axis_mode: AxisMode, + selecting_object: bool, + picked_entity: Option, + source_handle: Option, + mtext_override: bool, } impl LinearDimensionCommand { @@ -79,6 +94,12 @@ impl LinearDimensionCommand { awaiting_text: false, text_angle: None, awaiting_angle: false, + awaiting_rotation: false, + axis_mode: AxisMode::Automatic, + selecting_object: false, + picked_entity: None, + source_handle: None, + mtext_override: false, } } } @@ -94,18 +115,32 @@ impl CadCommand for LinearDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMLINEAR Enter dimension text (blank = measured value):").into_owned(); + return if self.mtext_override { + t!("DIMLINEAR Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMLINEAR Enter dimension text (blank = measured value):").into_owned() + }; } if self.awaiting_angle { return t!("DIMLINEAR Specify text angle (degrees):").into_owned(); } + if self.awaiting_rotation { + return t!("DIMLINEAR Specify dimension line angle (degrees):").into_owned(); + } + if self.selecting_object { + return t!("DIMLINEAR Select object to dimension:").into_owned(); + } match self.step { - Step::FirstPoint => t!("DIMLINEAR Specify first extension line origin:").into_owned(), + Step::FirstPoint => { + t!("DIMLINEAR Specify first extension line origin or press Enter to select object:") + .into_owned() + } Step::SecondPoint(_) => { - t!("DIMLINEAR Specify second extension line origin [Text/Angle]:").into_owned() + t!("DIMLINEAR Specify second extension line origin:").into_owned() } Step::DimensionLine { .. } => { - t!("DIMLINEAR Specify dimension line location [Text/Angle]:").into_owned() + t!("DIMLINEAR Specify dimension line location [Mtext/Text/Angle/Horizontal/Vertical/Rotated]:").into_owned() } } } @@ -117,6 +152,9 @@ impl CadCommand for LinearDimensionCommand { CmdResult::NeedPoint } Step::SecondPoint(first) => { + if pt.distance_squared(first) <= 1e-24 { + return CmdResult::NeedPoint; + } self.step = Step::DimensionLine { first, second: pt }; CmdResult::NeedPoint } @@ -125,21 +163,31 @@ impl CadCommand for LinearDimensionCommand { let second = self.plane.to_local(second); let pt = self.plane.to_local(pt); let mut dim = DimensionLinear::new(v3(first), v3(second)); - let axis = measure_axis(first, second, pt); + let axis = match self.axis_mode { + AxisMode::Automatic => measure_axis(first, second, pt), + AxisMode::Horizontal => DVec3::X, + AxisMode::Vertical => DVec3::Y, + AxisMode::Rotated(angle) => DVec3::new(angle.cos(), angle.sin(), 0.0), + }; dim.rotation = axis.y.atan2(axis.x); - dim.definition_point = v3(pt); - dim.base.definition_point = v3(pt); + dim.set_offset(dimension_line_offset(second, pt, axis)); + dim.base.definition_point = dim.definition_point; dim.base.text_middle_point = v3(linear_text_pos(first, second, pt, axis)); dim.base.insertion_point = dim.base.text_middle_point; dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); + dim.base.set_text_override(self.text_override.clone()); // An explicit text angle overrides the UCS-derived rotation. if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( + let entity = self.plane.place_entity(EntityType::Dimension( Dimension::Linear(dim), - ))) + )); + if let Some(source) = self.source_handle { + CmdResult::CommitAssociativeDimension { entity, source } + } else { + CmdResult::CommitAndExit(entity) + } } } } @@ -154,6 +202,14 @@ impl CadCommand for LinearDimensionCommand { self.awaiting_angle = false; return CmdResult::NeedPoint; } + if self.awaiting_rotation { + self.awaiting_rotation = false; + return CmdResult::NeedPoint; + } + if matches!(self.step, Step::FirstPoint) { + self.selecting_object = true; + return CmdResult::NeedPoint; + } CmdResult::Cancel } @@ -168,7 +224,7 @@ impl CadCommand for LinearDimensionCommand { fn point_step_accepts_keywords(&self) -> bool { // While typing the override text or angle, route input as a value, not // a point pick / keyword. - !self.awaiting_text && !self.awaiting_angle + !self.awaiting_text && !self.awaiting_angle && !self.awaiting_rotation } fn wants_text_with_spaces(&self) -> bool { @@ -199,8 +255,24 @@ impl CadCommand for LinearDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } + if self.awaiting_rotation { + if let Some(angle) = crate::entities::common::parse_typed_angle(text.trim()) { + self.axis_mode = AxisMode::Rotated(angle); + } + self.awaiting_rotation = false; + return Some(CmdResult::NeedPoint); + } + if !matches!(self.step, Step::DimensionLine { .. }) { + return None; + } match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; self.awaiting_text = true; Some(CmdResult::NeedPoint) } @@ -208,10 +280,54 @@ impl CadCommand for LinearDimensionCommand { self.awaiting_angle = true; Some(CmdResult::NeedPoint) } + "H" | "HORIZONTAL" => { + self.axis_mode = AxisMode::Horizontal; + Some(CmdResult::NeedPoint) + } + "V" | "VERTICAL" => { + self.axis_mode = AxisMode::Vertical; + Some(CmdResult::NeedPoint) + } + "R" | "ROTATED" => { + self.awaiting_rotation = true; + Some(CmdResult::NeedPoint) + } _ => None, } } + fn needs_entity_pick(&self) -> bool { + self.selecting_object + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: acadrust::Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some((first, second)) = dimension_source_points(&entity, point) else { + return CmdResult::NeedPoint; + }; + if first.distance_squared(second) <= 1e-24 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.selecting_object = false; + self.step = Step::DimensionLine { first, second }; + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { match self.step { Step::FirstPoint => None, @@ -235,6 +351,76 @@ fn v3(pt: DVec3) -> Vector3 { Vector3::new(pt.x, pt.y, pt.z) } +fn dimension_line_offset(second: DVec3, point: DVec3, axis: DVec3) -> f64 { + let perpendicular = DVec3::new(-axis.y, axis.x, 0.0); + (point - second).dot(perpendicular) +} + +fn dimension_source_points(entity: &EntityType, click: DVec3) -> Option<(DVec3, DVec3)> { + let point = |p: Vector3| DVec3::new(p.x, p.y, p.z); + match entity { + EntityType::Line(line) => Some((point(line.start), point(line.end))), + EntityType::Arc(arc) => Some((point(arc.start_point()), point(arc.end_point()))), + EntityType::LwPolyline(polyline) => { + nearest_lw_segment(polyline, click) + } + EntityType::Polyline2D(polyline) => { + let vertices = crate::entities::polyline::drawn_vertices2d(polyline) + .unwrap_or_else(|| polyline.vertices.clone()); + nearest_segment( + vertices.iter().map(|vertex| { + DVec3::new(vertex.location.x, vertex.location.y, vertex.location.z) + }), + polyline.is_closed(), + click, + ) + } + _ => None, + } +} + +fn nearest_lw_segment( + polyline: &acadrust::entities::LwPolyline, + click: DVec3, +) -> Option<(DVec3, DVec3)> { + nearest_segment( + polyline.vertices.iter().map(|vertex| { + DVec3::new(vertex.location.x, vertex.location.y, polyline.elevation) + }), + polyline.is_closed, + click, + ) +} + +fn nearest_segment( + points: impl IntoIterator, + closed: bool, + click: DVec3, +) -> Option<(DVec3, DVec3)> { + let points: Vec<_> = points.into_iter().collect(); + if points.len() < 2 { + return None; + } + let count = if closed { points.len() } else { points.len() - 1 }; + (0..count) + .map(|index| { + let first = points[index]; + let second = points[(index + 1) % points.len()]; + let direction = second - first; + let length_squared = direction.length_squared(); + let t = if length_squared <= 1e-24 { + 0.0 + } else { + (click - first).dot(direction) / length_squared + } + .clamp(0.0, 1.0); + let distance = click.distance_squared(first + direction * t); + (distance, first, second) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, first, second)| (first, second)) +} + fn preview_wire(points: Vec) -> WireModel { WireModel { point_marker: None, @@ -289,7 +475,23 @@ fn dim_line_endpoints(first: DVec3, second: DVec3, def: DVec3, axis: DVec3) -> ( fn linear_dimension_preview(first: DVec3, second: DVec3, def: DVec3, axis: DVec3) -> Vec { let (d1, d2) = dim_line_endpoints(first, second, def, axis); let nan = DVec3::new(f64::NAN, f64::NAN, f64::NAN); - vec![first, d1, nan, second, d2, nan, d1, d2] + let arrow = 0.22; + let perp = DVec3::new(-axis.y, axis.x, 0.0); + let text = linear_text_pos(first, second, def, axis); + let half_width = ((second - first).length().log10().max(0.0) + 1.0) * 0.18; + let half_height = 0.16; + vec![ + first, d1, nan, second, d2, nan, d1, d2, nan, + d1, d1 + axis * arrow + perp * arrow * 0.45, nan, + d1, d1 + axis * arrow - perp * arrow * 0.45, nan, + d2, d2 - axis * arrow + perp * arrow * 0.45, nan, + d2, d2 - axis * arrow - perp * arrow * 0.45, nan, + text - axis * half_width - perp * half_height, + text + axis * half_width - perp * half_height, + text + axis * half_width + perp * half_height, + text - axis * half_width + perp * half_height, + text - axis * half_width - perp * half_height, + ] } fn linear_text_pos(first: DVec3, second: DVec3, def: DVec3, axis: DVec3) -> DVec3 { From f0d00c1d53efc34c2dd7d1dfcf23e78a66fd59e7 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:10:29 +0300 Subject: [PATCH 25/98] Align linear dimension properties and grips --- src/app/properties.rs | 25 +- src/app/update/command.rs | 33 ++- src/app/update/mod.rs | 16 +- src/entities/dim_override.rs | 272 +++++++++++++++++++++- src/entities/dimension.rs | 439 ++++++++++++++++++++++++++++++++--- 5 files changed, 749 insertions(+), 36 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index a7a5088b..7b5e481c 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1021,7 +1021,11 @@ impl OpenCADStudio { && s.name.eq_ignore_ascii_case("Standard")) }) { - sections.extend(crate::entities::dimension::style_sections(style)); + sections.extend(crate::entities::dimension::style_sections( + style, + d, + &self.tabs[i].scene.document, + )); // The style groups are a read-only mirror, but the // dim-line colour is an editable per-object override: @@ -1036,6 +1040,25 @@ impl OpenCADStudio { "dim_line_color", PropValue::ColorChoice(dim_c), ); + for (field, code, inherited) in [ + ("dim_ext_line_color", dov::DIMCLRE, style.dimclre), + ("dim_text_color", dov::DIMCLRT, style.dimclrt), + ( + "dim_text_fill_color", + dov::DIMTFILLCLR, + style.dimtfillclr, + ), + ] { + let color = dov::color(&d.base().common.extended_data, code) + .unwrap_or_else(|| { + acadrust::types::Color::from_index(inherited) + }); + set_row_value( + &mut sections, + field, + PropValue::ColorChoice(color), + ); + } } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 83d00758..4ec4c44f 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1140,6 +1140,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if matches!( item.action, GripMenuAction::Stretch + | GripMenuAction::MoveWithDimLine | GripMenuAction::MoveWithLeader | GripMenuAction::MoveIndependent ) { @@ -1839,7 +1840,24 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); - if field == "vscale_std" { + if field.starts_with("dim_") { + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Dimension(_)) + ) { + crate::entities::dim_override::set_property( + &mut self.tabs[i].scene.document, + handle, + field, + &value, + ); + } + } + } else if field == "vscale_std" { for &handle in &handles { if matches!( self.tabs[i].scene.document.get_entity(handle), @@ -2410,6 +2428,19 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { continue; } match field { + _ if field.starts_with("dim_") => { + if matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Dimension(_)) + ) { + crate::entities::dim_override::set_property( + &mut self.tabs[i].scene.document, + handle, + field, + &val, + ); + } + } "hyperlink" => { // Stored in the standard PE_URL XDATA // record; an empty value clears it. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 679b3ad9..2dd58b15 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4834,8 +4834,20 @@ impl OpenCADStudio { // line with the rest of the dim-colour stack (index-only through // the file layer). Guarded to leaders / dimensions so a mixed // selection can't stamp the override onto other entities. - if field == "dim_line_color" { + if matches!( + field.as_str(), + "dim_line_color" + | "dim_ext_line_color" + | "dim_text_color" + | "dim_text_fill_color" + ) { let aci = color.approximate_index(); + let code = match field.as_str() { + "dim_ext_line_color" => crate::entities::dim_override::DIMCLRE, + "dim_text_color" => crate::entities::dim_override::DIMCLRT, + "dim_text_fill_color" => crate::entities::dim_override::DIMTFILLCLR, + _ => crate::entities::dim_override::DIMCLRD, + }; let targets: Vec = handles .iter() .copied() @@ -4853,7 +4865,7 @@ impl OpenCADStudio { crate::entities::dim_override::set( &mut self.tabs[i].scene.document, handle, - crate::entities::dim_override::DIMCLRD, + code, Some(acadrust::xdata::XDataValue::Integer16(aci)), ); } diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 7535f143..68843073 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -9,7 +9,7 @@ use acadrust::types::Color; use acadrust::xdata::{ExtendedData, XDataValue}; -use acadrust::{CadDocument, Handle}; +use acadrust::{CadDocument, EntityType, Handle}; // DXF group codes of the dimension variables surfaced on the leader panel. pub const DIMSCALE: i16 = 40; // overall scale (real) @@ -229,3 +229,273 @@ pub fn set(doc: &mut CadDocument, handle: Handle, code: i16, value: Option Option { + Some(match field { + "dim_arrow_size" => DIMASZ, + "dim_line_ext" => DIMDLE, + "dim_ext_line_ext" => DIMEXE, + "dim_ext_line_offset" => DIMEXO, + "dim_ext_line_fixed_length" => DIMFXL, + "dim_text_height" => DIMTXT, + "dim_text_offset" => DIMGAP, + "dim_scale_overall" => DIMSCALE, + "dim_roundoff" => DIMRND, + "dim_scale_linear" => DIMLFAC, + "dim_alt_scale_factor" => DIMALTF, + "dim_alt_roundoff" => DIMALTRND, + "dim_tolerance_limit_lower" => DIMTM, + "dim_tolerance_limit_upper" => DIMTP, + "dim_tolerance_text_height" => DIMTFAC, + _ => return None, + }) +} + +pub fn property_int_code(field: &str) -> Option { + Some(match field { + "dim_line_lineweight" => DIMLWD, + "dim_ext_line_lineweight" => DIMLWE, + "dim_ext_line_fixed" => DIMFXLON, + "dim_text_pos_vert" => DIMTAD, + "dim_text_pos_hor" => DIMJUST, + "dim_text_outside_align" => DIMTOH, + "dim_text_inside_align" => DIMTIH, + "dim_fit" => DIMATFIT, + "dim_text_inside" => DIMTIX, + "dim_text_movement" => DIMTMOVE, + "dim_line_forced" => DIMTOFL, + "dim_line_inside" => DIMSOXD, + "dim_units" => DIMLUNIT, + "dim_precision" => DIMDEC, + "dim_decimal_separator" => DIMDSEP, + "dim_angle_format" => DIMAUNIT, + "dim_angle_precision" => DIMADEC, + "dim_alt_enabled" => DIMALT, + "dim_alt_format" => DIMALTU, + "dim_alt_precision" => DIMALTD, + "dim_tolerance_precision" => DIMTDEC, + "dim_tolerance_pos_vert" => DIMTOLJ, + _ => return None, + }) +} + +pub fn property_string_code(field: &str) -> Option { + Some(match field { + "dim_prefix_suffix" => DIMPOST, + "dim_alt_prefix_suffix" => DIMAPOST, + _ => return None, + }) +} + +fn yes(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "yes" | "on" | "true" | "1" => Some(1), + "no" | "off" | "false" | "0" => Some(0), + _ => None, + } +} + +fn inherited_int(doc: &CadDocument, handle: Handle, code: i16) -> i16 { + let Some(EntityType::Dimension(dimension)) = doc.get_entity(handle) else { + return 0; + }; + if let Some(value) = int(&dimension.base().common.extended_data, code) { + return value; + } + let Some(style) = doc.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(&dimension.base().style_name) + || (dimension.base().style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) else { + return 0; + }; + match code { + DIMSD1 => style.dimsd1 as i16, + DIMSD2 => style.dimsd2 as i16, + DIMSE1 => style.dimse1 as i16, + DIMSE2 => style.dimse2 as i16, + DIMZIN => style.dimzin, + DIMALTZ => style.dimaltz, + DIMTZIN => style.dimtzin, + DIMTOL => style.dimtol as i16, + DIMLIM => style.dimlim as i16, + _ => 0, + } +} + +pub fn set_property( + doc: &mut CadDocument, + handle: Handle, + field: &str, + value: &str, +) -> bool { + let trimmed = value.trim(); + let handle_field = match field { + "dim_arrowhead_1" => Some((DIMBLK1, true)), + "dim_arrowhead_2" => Some((DIMBLK2, true)), + "dim_linetype" => Some((DIMLTYPE, false)), + "dim_ext_linetype_1" => Some((DIMLTEX1, false)), + "dim_ext_linetype_2" => Some((DIMLTEX2, false)), + _ => None, + }; + if let Some((code, block)) = handle_field { + let resolved = if block { + (trimmed != "Closed filled") + .then(|| { + doc.block_records + .iter() + .find(|record| record.name == trimmed) + .map(|record| record.handle) + }) + .flatten() + } else { + doc.line_types + .iter() + .find(|line_type| line_type.name == trimmed) + .map(|line_type| line_type.handle) + }; + set(doc, handle, code, resolved.map(XDataValue::Handle)); + return true; + } + let inverse_boolean_code = match field { + "dim_line_1" => Some(DIMSD1), + "dim_line_2" => Some(DIMSD2), + "dim_ext_line_1" => Some(DIMSE1), + "dim_ext_line_2" => Some(DIMSE2), + _ => None, + }; + if let Some(code) = inverse_boolean_code { + let Some(visible) = yes(trimmed) else { + return false; + }; + set(doc, handle, code, Some(XDataValue::Integer16((visible == 0) as i16))); + return true; + } + let bit_field = match field { + "dim_suppress_leading_zeros" => Some((DIMZIN, 4)), + "dim_suppress_trailing_zeros" => Some((DIMZIN, 8)), + "dim_alt_suppress_leading_zeros" => Some((DIMALTZ, 4)), + "dim_alt_suppress_trailing_zeros" => Some((DIMALTZ, 8)), + "dim_tolerance_suppress_leading_zeros" => Some((DIMTZIN, 4)), + "dim_tolerance_suppress_trailing_zeros" => Some((DIMTZIN, 8)), + _ => None, + }; + if let Some((code, bit)) = bit_field { + let Some(enabled) = yes(trimmed) else { + return false; + }; + let current = inherited_int(doc, handle, code); + let value = if enabled != 0 { + current | bit + } else { + current & !bit + }; + set(doc, handle, code, Some(XDataValue::Integer16(value))); + return true; + } + if field == "dim_tolerance_display" { + let (tolerance, limits) = match trimmed.to_ascii_lowercase().as_str() { + "deviation" => (1, 0), + "limits" => (0, 1), + "none" => (0, 0), + _ => return false, + }; + set(doc, handle, DIMTOL, Some(XDataValue::Integer16(tolerance))); + set(doc, handle, DIMLIM, Some(XDataValue::Integer16(limits))); + return true; + } + if let Some(code) = property_real_code(field) { + if trimmed.is_empty() { + set(doc, handle, code, None); + } else if let Ok(number) = trimmed.parse::() { + set(doc, handle, code, Some(XDataValue::Real(number))); + } else { + return false; + } + return true; + } + if let Some(code) = property_string_code(field) { + set( + doc, + handle, + code, + (!trimmed.is_empty()).then(|| XDataValue::String(value.to_string())), + ); + return true; + } + if let Some(code) = property_int_code(field) { + let parsed = match field { + "dim_ext_line_fixed" + | "dim_text_outside_align" + | "dim_text_inside_align" + | "dim_text_inside" + | "dim_line_forced" + | "dim_line_inside" + | "dim_alt_enabled" => yes(trimmed), + "dim_decimal_separator" => trimmed + .chars() + .next() + .map(|character| character as i16), + "dim_units" => match trimmed.to_ascii_lowercase().as_str() { + "scientific" => Some(1), + "decimal" => Some(2), + "engineering" => Some(3), + "architectural" => Some(4), + "fractional" => Some(5), + "desktop" => Some(6), + _ => trimmed.parse().ok(), + }, + "dim_angle_format" => match trimmed.to_ascii_lowercase().as_str() { + "decimal degrees" => Some(0), + "degrees/minutes/seconds" => Some(1), + "gradians" => Some(2), + "radians" => Some(3), + "surveyors units" => Some(4), + _ => trimmed.parse().ok(), + }, + "dim_text_pos_vert" => match trimmed.to_ascii_lowercase().as_str() { + "centered" => Some(0), + "above" => Some(1), + "outside" => Some(2), + "jis" => Some(3), + "below" => Some(4), + _ => trimmed.parse().ok(), + }, + "dim_text_pos_hor" => match trimmed.to_ascii_lowercase().as_str() { + "centered" => Some(0), + "at extension line 1" => Some(1), + "at extension line 2" => Some(2), + "over extension line 1" => Some(3), + "over extension line 2" => Some(4), + _ => trimmed.parse().ok(), + }, + "dim_fit" => match trimmed.to_ascii_lowercase().as_str() { + "either text or arrows" => Some(0), + "arrows" => Some(1), + "text" => Some(2), + "both text and arrows" => Some(3), + "always keep text" => Some(4), + _ => trimmed.parse().ok(), + }, + "dim_text_movement" => match trimmed.to_ascii_lowercase().as_str() { + "move dimension line" => Some(0), + "add leader" => Some(1), + "move text freely" => Some(2), + _ => trimmed.parse().ok(), + }, + "dim_tolerance_pos_vert" => match trimmed.to_ascii_lowercase().as_str() { + "bottom" => Some(0), + "middle" => Some(1), + "top" => Some(2), + _ => trimmed.parse().ok(), + }, + _ => trimmed.parse().ok(), + }; + let Some(number) = parsed else { + return false; + }; + set(doc, handle, code, Some(XDataValue::Integer16(number))); + return true; + } + false +} diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index adda3e8f..2b1708e6 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -60,6 +60,44 @@ fn base_props(base: &DimensionBase) -> Vec Vec { + if let Dimension::Linear(d) = dim { + let base = &d.base; + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![ + crate::scene::model::object::Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: crate::scene::model::object::PropValue::PlainText( + base.style_name.clone(), + ), + }, + ro( + t!("Measurement").as_ref(), + "measurement", + format!("{:.4}", d.measurement()), + ), + crate::scene::model::object::Property { + label: t!("Text override").into_owned(), + field: "text_override", + value: crate::scene::model::object::PropValue::PlainText( + base.text_override().unwrap_or("").to_string(), + ), + }, + edit_angle( + t!("Text rotation").as_ref(), + "text_rotation", + base.text_rotation.to_degrees(), + ), + edit_angle(t!("Dim line angle").as_ref(), "rotation", d.rotation.to_degrees()), + edit_angle( + t!("Extension line angle").as_ref(), + "ext_line_rotation", + d.ext_line_rotation.to_degrees(), + ), + ], + }]; + } let mut props = base_props(dim.base()); match dim { Dimension::Aligned(d) => { @@ -234,16 +272,12 @@ fn angular_props( fn apply_base_prop(base: &mut DimensionBase, field: &str, value: &str) -> bool { match field { - "text" => { - base.text = value.to_string(); - true - } - "user_text" => { - base.user_text = if value.trim().is_empty() { + "text" | "user_text" | "text_override" => { + base.set_text_override(if value.trim().is_empty() { None } else { Some(value.to_string()) - }; + }); true } "style_name" => { @@ -305,6 +339,8 @@ fn apply_geom_prop(dim: &mut Dimension, field: &str, value: &str) { Dimension::Arc(d) => apply_arc_fields(d, field, value), Dimension::LargeRadial(d) => apply_large_radial_fields(d, field, value), } + let definition_point = dim.definition_point(); + dim.base_mut().definition_point = definition_point; dim.base_mut().actual_measurement = dim.measurement(); } @@ -1031,6 +1067,24 @@ fn dimension_line_grip_position(dim: &Dimension) -> Option { Some((p1 + p2) * 0.5) } +fn above_dimension_text_position(dim: &Dimension) -> Option { + let center = dimension_line_grip_position(dim)?; + let (ax, ay) = match dim { + Dimension::Linear(d) => (d.rotation.cos(), d.rotation.sin()), + Dimension::Aligned(d) => { + let dx = d.second_point.x - d.first_point.x; + let dy = d.second_point.y - d.first_point.y; + let length = (dx * dx + dy * dy).sqrt(); + if length <= 1e-12 { + return None; + } + (dx / length, dy / length) + } + _ => return Some(center + DVec3::Y), + }; + Some(center + DVec3::new(-ay, ax, 0.0)) +} + impl Grippable for Dimension { fn grips(&self) -> Vec { // Auto-placed dimensions carry a zero text_middle_point sentinel; put @@ -1212,6 +1266,8 @@ impl Grippable for Dimension { restore_dim_text_relative_position(self, saved); } + let definition_point = self.definition_point(); + self.base_mut().definition_point = definition_point; self.base_mut().actual_measurement = self.measurement(); } @@ -1301,33 +1357,62 @@ impl Grippable for Dimension { b.text_middle_point.x = 0.0; b.text_middle_point.y = 0.0; b.text_middle_point.z = 0.0; + b.text_user_positioned = false; } A::Center if grip_id == text_grip => { - // Snap text to the centre of the dimension line. - // Approximate as midpoint of first/second extension - // origins for Linear / Aligned dimensions. - match self { - Dimension::Linear(d) => { - let mx = (d.first_point.x + d.second_point.x) * 0.5; - let my = (d.first_point.y + d.second_point.y) * 0.5; - d.base.text_middle_point.x = mx; - d.base.text_middle_point.y = my; - } - Dimension::Aligned(d) => { - let mx = (d.first_point.x + d.second_point.x) * 0.5; - let my = (d.first_point.y + d.second_point.y) * 0.5; - d.base.text_middle_point.x = mx; - d.base.text_middle_point.y = my; - } - _ => {} + if let Some(point) = dimension_line_grip_position(self) { + let base = self.base_mut(); + base.text_middle_point = Vector3::new(point.x, point.y, point.z); + base.text_user_positioned = true; + } + } + A::ReverseArrows => { + let base = self.base_mut(); + base.flip_arrow1 = !base.flip_arrow1; + base.flip_arrow2 = !base.flip_arrow2; + } + A::AboveDimLine if grip_id == text_grip => { + if let Some(point) = above_dimension_text_position(self) { + let base = self.base_mut(); + base.text_middle_point = Vector3::new(point.x, point.y + 1.0, point.z); + base.text_user_positioned = true; } } - // Stretch / Move-variants / Reverse Arrows / Rotate Text / - // Above Dim Line need either a follow-up drag or a numeric - // prompt — wired to default Stretch behaviour for now. _ => {} } } + + fn grip_menu_value_prompt( + &self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + ) -> Option<&'static str> { + use crate::scene::model::object::GripMenuAction as A; + let text_grip = match self { + Dimension::Linear(_) | Dimension::Aligned(_) => 3, + Dimension::Radius(_) | Dimension::Diameter(_) => 2, + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => 4, + Dimension::Ordinate(_) => 3, + Dimension::Arc(d) => if d.has_leader { 6 } else { 4 }, + Dimension::LargeRadial(_) => 4, + }; + (grip_id == text_grip && matches!(action, A::RotateText)) + .then_some("Specify text rotation") + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + value: f64, + ) { + use crate::scene::model::object::GripMenuAction as A; + if self.grip_menu_value_prompt(grip_id, action).is_some() + && matches!(action, A::RotateText) + { + self.base_mut().text_rotation = value.to_radians(); + } + } } // ── Tessellation ───────────────────────────────────────────────────────── @@ -1347,7 +1432,11 @@ use acadrust::types::{Color as AcadColor, Vector3}; /// Units, Alternate Units, Tolerances) built from the resolved DimStyle. These /// are read-only mirrors of the style's dimension variables, injected by the /// panel after the entity's own Misc/Geometry groups. -pub fn style_sections(style: &DimStyle) -> Vec { +pub fn style_sections( + style: &DimStyle, + dimension: &Dimension, + document: &CadDocument, +) -> Vec { let s = style; let yn = |b: bool| if b { "Yes" } else { "No" }; let onoff = |b: bool| if b { "On" } else { "Off" }; @@ -1368,11 +1457,36 @@ pub fn style_sections(style: &DimStyle) -> Vec Vec value.clone(), + _ => continue, + }; + if let Some(code) = crate::entities::dim_override::property_real_code(property.field) { + let value = crate::entities::dim_override::real(data, code) + .map(|number| number.to_string()) + .unwrap_or(inherited); + property.value = crate::scene::model::object::PropValue::EditText(value); + continue; + } + if let Some(code) = crate::entities::dim_override::property_string_code(property.field) { + let value = crate::entities::dim_override::string(data, code).unwrap_or(inherited); + property.value = crate::scene::model::object::PropValue::PlainText(value); + continue; + } + if let Some(code) = crate::entities::dim_override::property_int_code(property.field) { + let number = crate::entities::dim_override::int(data, code); + let value = number.map(|number| number.to_string()).unwrap_or(inherited); + property.value = match property.field { + "dim_ext_line_fixed" + | "dim_text_outside_align" + | "dim_text_inside_align" + | "dim_text_inside" + | "dim_line_forced" + | "dim_line_inside" + | "dim_alt_enabled" => crate::scene::model::object::PropValue::Choice { + selected: number.map(|number| yes_no(number != 0)).unwrap_or(value), + options: vec!["Yes".to_string(), "No".to_string()], + }, + "dim_units" => crate::scene::model::object::PropValue::Choice { + selected: linear_unit_label(number.unwrap_or(s.dimlunit)).to_string(), + options: vec![ + "Scientific", "Decimal", "Engineering", "Architectural", + "Fractional", "Desktop", + ] + .into_iter() + .map(str::to_string) + .collect(), + }, + "dim_angle_format" => crate::scene::model::object::PropValue::Choice { + selected: angle_unit_label(number.unwrap_or(s.dimaunit)).to_string(), + options: vec![ + "Decimal degrees", "Degrees/minutes/seconds", "Gradians", + "Radians", "Surveyors units", + ] + .into_iter() + .map(str::to_string) + .collect(), + }, + "dim_text_pos_vert" => choice_value( + match number.unwrap_or(s.dimtad) { + 1 => "Above", + 2 => "Outside", + 3 => "JIS", + 4 => "Below", + _ => "Centered", + }, + &["Centered", "Above", "Outside", "JIS", "Below"], + ), + "dim_text_pos_hor" => choice_value( + match number.unwrap_or(s.dimjust) { + 1 => "At extension line 1", + 2 => "At extension line 2", + 3 => "Over extension line 1", + 4 => "Over extension line 2", + _ => "Centered", + }, + &[ + "Centered", "At extension line 1", "At extension line 2", + "Over extension line 1", "Over extension line 2", + ], + ), + "dim_fit" => choice_value( + match number.unwrap_or(s.dimatfit) { + 1 => "Arrows", + 2 => "Text", + 3 => "Both text and arrows", + 4 => "Always keep text", + _ => "Either text or arrows", + }, + &[ + "Either text or arrows", "Arrows", "Text", + "Both text and arrows", "Always keep text", + ], + ), + "dim_text_movement" => choice_value( + match number.unwrap_or(s.dimtmove) { + 1 => "Add leader", + 2 => "Move text freely", + _ => "Move dimension line", + }, + &["Move dimension line", "Add leader", "Move text freely"], + ), + "dim_tolerance_pos_vert" => choice_value( + match number.unwrap_or(s.dimtolj) { + 1 => "Middle", + 2 => "Top", + _ => "Bottom", + }, + &["Bottom", "Middle", "Top"], + ), + _ => crate::scene::model::object::PropValue::EditText(value), + }; + } + } + + let integer_or = |code, inherited| { + crate::entities::dim_override::int(data, code).unwrap_or(inherited) + }; + for property in sections + .iter_mut() + .flat_map(|section| section.props.iter_mut()) + { + let selected = match property.field { + "dim_line_1" => Some(if integer_or(crate::entities::dim_override::DIMSD1, s.dimsd1 as i16) == 0 { "On" } else { "Off" }), + "dim_line_2" => Some(if integer_or(crate::entities::dim_override::DIMSD2, s.dimsd2 as i16) == 0 { "On" } else { "Off" }), + "dim_ext_line_1" => Some(if integer_or(crate::entities::dim_override::DIMSE1, s.dimse1 as i16) == 0 { "On" } else { "Off" }), + "dim_ext_line_2" => Some(if integer_or(crate::entities::dim_override::DIMSE2, s.dimse2 as i16) == 0 { "On" } else { "Off" }), + "dim_suppress_leading_zeros" => Some(if integer_or(crate::entities::dim_override::DIMZIN, s.dimzin) & 4 != 0 { "Yes" } else { "No" }), + "dim_suppress_trailing_zeros" => Some(if integer_or(crate::entities::dim_override::DIMZIN, s.dimzin) & 8 != 0 { "Yes" } else { "No" }), + "dim_alt_suppress_leading_zeros" => Some(if integer_or(crate::entities::dim_override::DIMALTZ, s.dimaltz) & 4 != 0 { "Yes" } else { "No" }), + "dim_alt_suppress_trailing_zeros" => Some(if integer_or(crate::entities::dim_override::DIMALTZ, s.dimaltz) & 8 != 0 { "Yes" } else { "No" }), + "dim_tolerance_suppress_leading_zeros" => Some(if integer_or(crate::entities::dim_override::DIMTZIN, s.dimtzin) & 4 != 0 { "Yes" } else { "No" }), + "dim_tolerance_suppress_trailing_zeros" => Some(if integer_or(crate::entities::dim_override::DIMTZIN, s.dimtzin) & 8 != 0 { "Yes" } else { "No" }), + _ => None, + }; + if let Some(selected) = selected { + let options = if matches!( + property.field, + "dim_line_1" | "dim_line_2" | "dim_ext_line_1" | "dim_ext_line_2" + ) { + &["On", "Off"][..] + } else { + &["Yes", "No"][..] + }; + property.value = choice_value(selected, options); + } + if property.field == "dim_tolerance_display" { + let limits = integer_or(crate::entities::dim_override::DIMLIM, s.dimlim as i16) != 0; + let tolerance = integer_or(crate::entities::dim_override::DIMTOL, s.dimtol as i16) != 0; + let selected = if limits { "Limits" } else if tolerance { "Deviation" } else { "None" }; + property.value = choice_value(selected, &["None", "Deviation", "Limits"]); + } + } + + let arrow_options: Vec = std::iter::once("Closed filled".to_string()) + .chain( + document + .block_records + .iter() + .map(|record| record.name.clone()) + .filter(|name| !name.is_empty()), + ) + .collect(); + let linetype_options: Vec = document + .line_types + .iter() + .map(|line_type| line_type.name.clone()) + .filter(|name| !name.is_empty()) + .collect(); + for property in sections + .iter_mut() + .flat_map(|section| section.props.iter_mut()) + { + let mut current = match &property.value { + crate::scene::model::object::PropValue::ReadOnly(value) => value.clone(), + _ => continue, + }; + if matches!(property.field, "dim_arrowhead_1" | "dim_arrowhead_2") { + let code = if property.field == "dim_arrowhead_1" { + crate::entities::dim_override::DIMBLK1 + } else { + crate::entities::dim_override::DIMBLK2 + }; + if let Some(handle) = crate::entities::dim_override::handle(data, code) { + current = block_name(document, handle, ""); + } + property.value = crate::scene::model::object::PropValue::Choice { + selected: current, + options: arrow_options.clone(), + }; + } else if matches!( + property.field, + "dim_linetype" | "dim_ext_linetype_1" | "dim_ext_linetype_2" + ) { + let code = match property.field { + "dim_linetype" => crate::entities::dim_override::DIMLTYPE, + "dim_ext_linetype_1" => crate::entities::dim_override::DIMLTEX1, + _ => crate::entities::dim_override::DIMLTEX2, + }; + if let Some(handle) = crate::entities::dim_override::handle(data, code) { + current = linetype_name(document, handle); + } + property.value = crate::scene::model::object::PropValue::Choice { + selected: current, + options: linetype_options.clone(), + }; + } + } + sections +} + +fn choice_value( + selected: &str, + options: &[&str], +) -> crate::scene::model::object::PropValue { + crate::scene::model::object::PropValue::Choice { + selected: selected.to_string(), + options: options.iter().map(|value| (*value).to_string()).collect(), + } +} + +fn block_name(document: &CadDocument, handle: acadrust::Handle, fallback: &str) -> String { + if handle.is_null() { + return if fallback.is_empty() { + "Closed filled".to_string() + } else { + fallback.to_string() + }; + } + document + .block_records + .iter() + .find(|record| record.handle == handle) + .map(|record| record.name.clone()) + .unwrap_or_else(|| fallback.to_string()) +} + +fn linetype_name(document: &CadDocument, handle: acadrust::Handle) -> String { + document + .line_types + .iter() + .find(|line_type| line_type.handle == handle) + .map(|line_type| line_type.name.clone()) + .unwrap_or_else(|| "ByBlock".to_string()) +} + +fn linear_unit_label(value: i16) -> &'static str { + match value { + 1 => "Scientific", + 3 => "Engineering", + 4 => "Architectural", + 5 => "Fractional", + 6 => "Desktop", + _ => "Decimal", + } +} + +fn angle_unit_label(value: i16) -> &'static str { + match value { + 1 => "Degrees/minutes/seconds", + 2 => "Gradians", + 3 => "Radians", + 4 => "Surveyors units", + _ => "Decimal degrees", + } } use acadrust::{CadDocument, EntityType, Handle}; @@ -4143,4 +4521,3 @@ mod arch_format_tests { assert_eq!(format_fractional(6.5, 0), "6 1/2"); } } - From 4a08f7fe80c81c223cb29d95aa463c3b43b928a1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 13:00:04 +0300 Subject: [PATCH 26/98] Complete table creation and editing workflow --- src/app/commands/display.rs | 603 +++++++++++++++++++++++++--- src/app/commands/mod.rs | 1 + src/app/properties.rs | 86 ++++ src/app/update/command.rs | 25 +- src/app/update/mod.rs | 47 +++ src/app/update/viewport.rs | 89 +++++ src/entities/table.rs | 625 +++++++++++++++++++++++++----- src/modules/annotate/data_link.rs | 116 +++++- src/modules/annotate/table_cmd.rs | 535 +++++++++++++++++++------ 9 files changed, 1848 insertions(+), 279 deletions(-) diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index ccf235a1..14f4ad35 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -12,60 +12,320 @@ impl OpenCADStudio { .push_output(crate::t!("PAN: drag with the left mouse button. Press Esc to exit.").as_ref()); } - // ── TABLE cell editing ───────────────────────────────────────────── - // TABLE CELL — set text for a cell in the selected Table + // ── TABLE structure and cell editing ─────────────────────────────── cmd if cmd.starts_with("TABLE ") => { let rest = cmd.trim_start_matches("TABLE").trim(); - let sub_up = rest.split_whitespace().next().unwrap_or("").to_uppercase(); - if sub_up == "CELL" { - let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); - // parts: ["CELL", "", "", ""] - let row_res = parts.get(1).and_then(|s| s.parse::().ok()); - let col_res = parts.get(2).and_then(|s| s.parse::().ok()); - let text = parts.get(3).copied().unwrap_or(""); - match (row_res, col_res) { - (Some(row), Some(col)) => { - let selected_handles: Vec = self.tabs[i] - .scene - .selected_entities() - .iter() - .map(|(h, _)| *h) - .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) - .collect(); - let mut found = false; - for sh in &selected_handles { - if let Some(acadrust::EntityType::Table(tbl)) = self.tabs[i] - .scene - .document - .entities_mut() - .find(|e| e.common().handle == *sh) - { - if tbl.set_cell_text(row, col, text) { - found = true; - } - } - } - if found { - self.push_undo_snapshot(i, "TABLE CELL"); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "TABLE CELL: set [{row},{col}] = \"{text}\"." - ).as_ref()); - } else { - self.command_line.push_error( - crate::t!("TABLE CELL: select a Table entity first, or row/col out of range.").as_ref() - ); - } - } - _ => { - self.command_line - .push_info(crate::t!("Usage: TABLE CELL ").as_ref()); + #[derive(Clone)] + enum TableAction { + Cell(usize, usize, String), + InsertRow(usize), + DeleteRow(usize), + InsertColumn(usize), + DeleteColumn(usize), + Merge(usize, usize, usize, usize), + Unmerge(usize, usize), + RowHeight(usize, f64), + ColumnWidth(usize, f64), + Lock(usize, usize, bool), + Block(usize, usize, String), + Formula(usize, usize, String), + Field(usize, usize, acadrust::Handle), + } + let words: Vec<&str> = rest.split_whitespace().collect(); + let integer = |index: usize| { + words.get(index).and_then(|value| value.parse::().ok()) + }; + let real = |index: usize| { + words.get(index).and_then(|value| value.parse::().ok()) + }; + let action = match words.first().map(|word| word.to_ascii_uppercase()).as_deref() { + Some("CELL") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2)) { + (Some(row), Some(column)) => Some(TableAction::Cell( + row, + column, + parts.get(3).copied().unwrap_or("").to_string(), + )), + _ => None, } } - } else { + Some("INSERTROW") => integer(1).map(TableAction::InsertRow), + Some("DELETEROW") => integer(1).map(TableAction::DeleteRow), + Some("INSERTCOLUMN") | Some("INSERTCOL") => { + integer(1).map(TableAction::InsertColumn) + } + Some("DELETECOLUMN") | Some("DELETECOL") => { + integer(1).map(TableAction::DeleteColumn) + } + Some("MERGE") => match (integer(1), integer(2), integer(3), integer(4)) { + (Some(r1), Some(c1), Some(r2), Some(c2)) => { + Some(TableAction::Merge(r1, c1, r2, c2)) + } + _ => None, + }, + Some("UNMERGE") => match (integer(1), integer(2)) { + (Some(row), Some(column)) => Some(TableAction::Unmerge(row, column)), + _ => None, + }, + Some("ROWHEIGHT") => match (integer(1), real(2)) { + (Some(row), Some(height)) if height > 0.0 => { + Some(TableAction::RowHeight(row, height)) + } + _ => None, + }, + Some("COLUMNWIDTH") | Some("COLWIDTH") => match (integer(1), real(2)) { + (Some(column), Some(width)) if width > 0.0 => { + Some(TableAction::ColumnWidth(column, width)) + } + _ => None, + }, + Some("LOCK") => match (integer(1), integer(2), words.get(3)) { + (Some(row), Some(column), Some(value)) => Some(TableAction::Lock( + row, + column, + !value.eq_ignore_ascii_case("OFF"), + )), + _ => None, + }, + Some("BLOCK") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2), parts.get(3)) { + (Some(row), Some(column), Some(name)) if !name.trim().is_empty() => { + Some(TableAction::Block(row, column, name.trim().to_string())) + } + _ => None, + } + } + Some("FORMULA") => { + let parts: Vec<&str> = rest.splitn(4, char::is_whitespace).collect(); + match (integer(1), integer(2), parts.get(3)) { + (Some(row), Some(column), Some(expression)) + if !expression.trim().is_empty() => + { + Some(TableAction::Formula( + row, + column, + expression.trim().to_string(), + )) + } + _ => None, + } + } + Some("FIELD") => match (integer(1), integer(2), words.get(3)) { + (Some(row), Some(column), Some(value)) => { + u64::from_str_radix(value.trim_start_matches("0x"), 16) + .ok() + .map(|handle| { + TableAction::Field( + row, + column, + acadrust::Handle::new(handle), + ) + }) + } + _ => None, + }, + _ => None, + }; + let Some(action) = action else { self.command_line.push_info( - "Usage: TABLE (creates new table) or TABLE CELL ", + "Usage: TABLE CELL | INSERTROW | DELETEROW | INSERTCOL | DELETECOL | MERGE | UNMERGE | ROWHEIGHT | COLWIDTH | LOCK | BLOCK | FORMULA | FIELD", ); + return Some(Task::none()); + }; + let selected_handles: Vec = self.tabs[i] + .scene + .selected_entities() + .iter() + .map(|(handle, _)| *handle) + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .collect(); + if selected_handles.is_empty() { + self.command_line + .push_error("TABLE: select a table first."); + return Some(Task::none()); + } + let block_handle = if let TableAction::Block(_, _, name) = &action { + self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.name.eq_ignore_ascii_case(name)) + .map(|record| record.handle) + } else { + None + }; + if matches!(action, TableAction::Block(_, _, _)) && block_handle.is_none() { + self.command_line.push_error("TABLE: block definition not found."); + return Some(Task::none()); + } + if let TableAction::Field(_, _, field_handle) = &action { + if !self.tabs[i].scene.document.fields.contains_key(field_handle) { + self.command_line.push_error("TABLE: field handle not found."); + return Some(Task::none()); + } + } + self.push_undo_snapshot(i, "TABLE EDIT"); + let mut changed = false; + for handle in &selected_handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(*handle) + else { + continue; + }; + match &action { + TableAction::Cell(row, column, text) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.set_text(text); + changed = true; + } + } + } + TableAction::InsertRow(row) if *row <= table.row_count() => { + let height = table + .rows + .get((*row).min(table.row_count().saturating_sub(1))) + .map(|row| row.height) + .unwrap_or(0.5); + table.insert_row(*row); + table.set_row_height(*row, height); + changed = true; + } + TableAction::DeleteRow(row) + if table.row_count() > 1 && *row < table.row_count() => + { + table.remove_row(*row); + changed = true; + } + TableAction::InsertColumn(column) if *column <= table.column_count() => { + let width = table + .columns + .get((*column).min(table.column_count().saturating_sub(1))) + .map(|column| column.width) + .unwrap_or(2.0); + table.insert_column(*column, width); + changed = true; + } + TableAction::DeleteColumn(column) + if table.column_count() > 1 && *column < table.column_count() => + { + table.remove_column(*column); + changed = true; + } + TableAction::Merge(r1, c1, r2, c2) + if *r1 < table.row_count() + && *r2 < table.row_count() + && *c1 < table.column_count() + && *c2 < table.column_count() => + { + table.merge_cells(acadrust::entities::table::CellRange::new( + (*r1).min(*r2), + (*c1).min(*c2), + (*r1).max(*r2), + (*c1).max(*c2), + )); + changed = true; + } + TableAction::Unmerge(row, column) => { + table.unmerge_cell(*row, *column); + changed = true; + } + TableAction::RowHeight(row, height) if *row < table.row_count() => { + table.set_row_height(*row, *height); + changed = true; + } + TableAction::ColumnWidth(column, width) + if *column < table.column_count() => + { + table.set_column_width(*column, *width); + changed = true; + } + TableAction::Lock(row, column, locked) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + cell.state.set(CellStateFlags::CONTENT_LOCKED, *locked); + cell.state.set(CellStateFlags::FORMAT_LOCKED, *locked); + changed = true; + } + } + TableAction::Block(row, column, _) => { + if let (Some(handle), Some(cell)) = + (block_handle, table.cell_mut(*row, *column)) + { + use acadrust::entities::table::{ + CellContent, CellStateFlags, CellType, + }; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.contents.clear(); + cell.contents.push(CellContent::block(handle)); + cell.cell_type = CellType::Block; + changed = true; + } + } + } + TableAction::Formula(row, column, expression) => { + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + let formula = if expression.starts_with('=') { + expression.clone() + } else { + format!("={expression}") + }; + cell.set_text(&formula); + changed = true; + } + } + } + TableAction::Field(row, column, field_handle) => { + let mut attached = false; + if let Some(cell) = table.cell_mut(*row, *column) { + use acadrust::entities::table::{ + CellContent, CellStateFlags, CellType, + }; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + let mut content = CellContent::text(""); + content.field_handle = Some(*field_handle); + cell.contents.clear(); + cell.contents.push(content); + cell.cell_type = CellType::Text; + attached = true; + } + } + if attached { + if !table.field_handles.contains(field_handle) { + table.field_handles.push(*field_handle); + } + changed = true; + } + } + _ => {} + } + } + if changed { + self.invalidate_property_targets(i, &selected_handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output("TABLE: edit applied."); + } else { + self.command_line + .push_error("TABLE: row/column is out of range or the cell is locked."); } } @@ -1161,8 +1421,7 @@ impl OpenCADStudio { return Some(Task::done(Message::AnnoObjectScaleOpen)); } - // DATALINK — import a CSV file into a table placed at the - // origin (one-time import; a live re-reading link is future work). + // DATALINK — create a persistent linked table. "DATALINK" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("DATALINK", "DATALINK path to the .csv file:"); @@ -1173,17 +1432,13 @@ impl OpenCADStudio { let path = cmd.trim_start_matches("DATALINK").trim(); if path.is_empty() { self.command_line.push_info( - "Usage: DATALINK — imports the CSV into a table at the origin.", + "Usage: DATALINK ", ); return Some(Task::none()); } match std::fs::read_to_string(path) { Ok(text) => { - let rows_data: Vec> = text - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|line| line.split(',').map(|s| s.trim().to_string()).collect()) - .collect(); + let rows_data = parse_csv_table(&text); let nrows = rows_data.len(); let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0); if nrows == 0 || ncols == 0 { @@ -1203,14 +1458,27 @@ impl OpenCADStudio { table.set_cell_text(r, c, cell); } } - self.push_undo_snapshot(i, "DATALINK"); - self.tabs[i] - .scene - .add_entity_clone(acadrust::EntityType::Table(table)); - self.tabs[i].dirty = true; - self.command_line.push_output(crate::tf!( - "DATALINK: imported {nrows}×{ncols} cells into a table at the origin." - ).as_ref()); + let doc = &self.tabs[i].scene.document; + let current_style = doc.header.current_table_style_name.clone(); + table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| { + match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(¤t_style) => + { + Some(*handle) + } + _ => None, + } + }); + let link_path = std::fs::canonicalize(path) + .map(|value| value.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string()); + let command = crate::modules::annotate::data_link::DataLinkPlaceCommand::new( + table, &link_path, + ); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); } Err(e) => { self.command_line @@ -1219,6 +1487,151 @@ impl OpenCADStudio { } } + cmd if cmd == "DATALINKUPDATE" || cmd.starts_with("DATALINKUPDATE ") => { + let write_back = cmd + .trim_start_matches("DATALINKUPDATE") + .trim() + .eq_ignore_ascii_case("WRITE"); + let selected: Vec = self.tabs[i] + .scene + .selected_entities() + .into_iter() + .filter_map(|(handle, entity)| { + matches!(entity, acadrust::EntityType::Table(_)).then_some(handle) + }) + .collect(); + let table_handles = if selected.is_empty() { + self.tabs[i] + .scene + .document + .entities() + .filter_map(|entity| { + matches!(entity, acadrust::EntityType::Table(_)) + .then_some(entity.common().handle) + }) + .collect::>() + } else { + selected + }; + let mut jobs = Vec::new(); + for handle in &table_handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity(*handle) + else { + continue; + }; + let link_handle = table + .rows + .iter() + .flat_map(|row| row.cells.iter()) + .find_map(|cell| cell.data_link_handle); + let Some(link_handle) = link_handle else { + continue; + }; + let path = match self.tabs[i].scene.document.objects.get(&link_handle) { + Some(acadrust::objects::ObjectType::ClassObject(object)) => { + match &object.data { + acadrust::objects::ClassObjectData::DataLink(link) => { + Some(link.connection_string.clone()) + } + _ => None, + } + } + _ => None, + }; + if let Some(path) = path { + jobs.push((*handle, link_handle, path)); + } + } + if jobs.is_empty() { + self.command_line + .push_error("DATALINKUPDATE: no linked tables found."); + return Some(Task::none()); + } + if write_back { + let mut written = 0usize; + for (table_handle, _, path) in &jobs { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity(*table_handle) + else { + continue; + }; + let csv = table_to_csv(table); + if std::fs::write(path, csv).is_ok() { + written += 1; + } + } + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: wrote {} linked source(s).", written).as_ref(), + ); + return Some(Task::none()); + } + let updates: Vec<_> = jobs + .into_iter() + .filter_map(|(table_handle, link_handle, path)| { + std::fs::read_to_string(&path) + .ok() + .map(|text| (table_handle, link_handle, parse_csv_table(&text))) + }) + .filter(|(_, _, rows)| !rows.is_empty()) + .collect(); + if updates.is_empty() { + self.command_line + .push_error("DATALINKUPDATE: linked sources could not be read."); + return Some(Task::none()); + } + self.push_undo_snapshot(i, "DATALINKUPDATE"); + use acadrust::entities::table::CellStateFlags; + let mut changed_handles = Vec::new(); + for (table_handle, link_handle, rows) in updates { + let columns = rows.iter().map(Vec::len).max().unwrap_or(0); + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(table_handle) + else { + continue; + }; + while table.row_count() < rows.len() { + table.add_row(); + } + while table.row_count() > rows.len() { + table.remove_row(table.row_count() - 1); + } + while table.column_count() < columns { + let width = table.columns.last().map(|column| column.width).unwrap_or(2.0); + table.add_column(width); + } + while table.column_count() > columns { + table.remove_column(table.column_count() - 1); + } + for row in 0..rows.len() { + for column in 0..columns { + if let Some(cell) = table.cell_mut(row, column) { + cell.set_text( + rows[row].get(column).map(String::as_str).unwrap_or(""), + ); + cell.has_linked_data = true; + cell.data_link_handle = Some(link_handle); + cell.data_link_rows = rows.len() as i32; + cell.data_link_columns = columns as i32; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + } + changed_handles.push(table_handle); + } + self.invalidate_property_targets(i, &changed_handles); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!("DATALINKUPDATE: updated {} linked table(s).", changed_handles.len()) + .as_ref(), + ); + } + // LANDXMLIMPORT — import survey points (LandXML // elements) as Point objects. Reads the coordinate text content // (northing easting elevation) → Point at (easting, northing, elev). @@ -1313,6 +1726,68 @@ fn parse_landxml_cgpoints(xml: &str) -> Vec<[f64; 3]> { out } +fn parse_csv_table(text: &str) -> Vec> { + let mut rows = Vec::new(); + let mut row = Vec::new(); + let mut field = String::new(); + let mut quoted = false; + let mut chars = text.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '"' if quoted && chars.peek() == Some(&'"') => { + field.push('"'); + chars.next(); + } + '"' => quoted = !quoted, + ',' if !quoted => row.push(std::mem::take(&mut field)), + '\n' if !quoted => { + if field.ends_with('\r') { + field.pop(); + } + row.push(std::mem::take(&mut field)); + if row.iter().any(|value| !value.is_empty()) { + rows.push(std::mem::take(&mut row)); + } else { + row.clear(); + } + } + _ => field.push(ch), + } + } + if field.ends_with('\r') { + field.pop(); + } + if !field.is_empty() || !row.is_empty() { + row.push(field); + if row.iter().any(|value| !value.is_empty()) { + rows.push(row); + } + } + rows +} + +fn table_to_csv(table: &acadrust::entities::Table) -> String { + fn escape(value: &str) -> String { + if value.contains([',', '"', '\r', '\n']) { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } + } + table + .rows + .iter() + .map(|row| { + row.cells + .iter() + .map(|cell| escape(cell.text_value())) + .collect::>() + .join(",") + }) + .collect::>() + .join("\r\n") +} + #[cfg(test)] mod tests { use crate::app::OpenCADStudio; diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 63bc9f7b..5e929c3e 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -390,6 +390,7 @@ inventory::submit!(crate::command::CommandRegistration { "OBJECTSCALE", // Import CSV into a table + LandXML survey points. "DATALINK", + "DATALINKUPDATE", "LANDXMLIMPORT", // Keyboard-shortcut (CUI) export / import. "CUIEXPORT", diff --git a/src/app/properties.rs b/src/app/properties.rs index a7a5088b..9f81392a 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -128,6 +128,9 @@ impl OpenCADStudio { crate::entities::spline::control_vertex_count(spline) }) } + acadrust::EntityType::Table(table) => { + Some(table.row_count().saturating_mul(table.column_count())) + } _ => None, }); vertex_count.map_or(prop_vertex, |count| prop_vertex.min(count.saturating_sub(1))) @@ -140,6 +143,7 @@ impl OpenCADStudio { false }; crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); + crate::entities::table::set_prop_current_cell(prop_vertex); let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let new_panel = { @@ -1294,6 +1298,43 @@ impl OpenCADStudio { } } } + acadrust::EntityType::Table(table) => { + let mut names: Vec = doc + .objects + .values() + .filter_map(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) + if !style.name.trim().is_empty() => + { + Some(style.name.clone()) + } + _ => None, + }) + .collect(); + names.sort_by_key(|name| name.to_ascii_lowercase()); + names.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); + let selected = table + .table_style_handle + .and_then(|handle| doc.objects.get(&handle)) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style.name.clone()) + } + _ => None, + }) + .unwrap_or_else(|| "Standard".to_string()); + if !names.iter().any(|name| name.eq_ignore_ascii_case(&selected)) { + names.insert(0, selected.clone()); + } + set_row_value( + &mut sections, + "tbl_style_handle", + crate::scene::model::object::PropValue::Choice { + selected, + options: names, + }, + ); + } _ => {} } @@ -1874,6 +1915,51 @@ impl OpenCADStudio { mut entity: acadrust::EntityType, ) -> Option { let i = self.active_tab; + if let acadrust::EntityType::Table(table) = &mut entity { + const PREFIX: &str = "__OPENCAD_LINK_PENDING__"; + if let Some(path) = table.name.strip_prefix(PREFIX).map(str::to_string) { + use acadrust::entities::table::CellStateFlags; + use acadrust::objects::{ + ClassObject, ClassObjectData, DataLink, ObjectType, + }; + let document = &mut self.tabs[i].scene.document; + let link_handle = document.allocate_handle(); + let mut object = ClassObject::new(ClassObjectData::DataLink(DataLink { + data_adapter: "CSV".to_string(), + description: path.clone(), + tooltip: path.clone(), + connection_string: path.clone(), + status_flags: 1, + update_status: "Linked".to_string(), + ..DataLink::default() + })); + object.handle = link_handle; + document + .objects + .insert(link_handle, ObjectType::ClassObject(object)); + let rows = table.row_count() as i32; + let columns = table.column_count() as i32; + for row in &mut table.rows { + for cell in &mut row.cells { + cell.has_linked_data = true; + cell.data_link_handle = Some(link_handle); + cell.data_link_rows = rows; + cell.data_link_columns = columns; + cell.state.insert( + CellStateFlags::LINKED + | CellStateFlags::CONTENT_LOCKED + | CellStateFlags::FORMAT_LOCKED, + ); + } + } + table.name = std::path::Path::new(&path) + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("Linked table") + .to_string(); + table.description = path; + } + } let tracks_draw_anchor = self.tabs[i].active_cmd.is_some() && matches!( &entity, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 83d00758..2a74327b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2364,7 +2364,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { return Task::none(); } self.push_undo_snapshot(i, "CHPROP"); - if field == "block" { + if field == "tbl_style_handle" { + let style_handle = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(val.trim()) => + { + Some(*handle) + } + _ => None, + }); + if let Some(style_handle) = style_handle { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.table_style_handle = Some(style_handle); + } + } + } + } else if field == "block" { // Name row on a block reference: an existing name // re-points the selected inserts; a new one renames // the definition they share. Commit closes the list. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 679b3ad9..c37894f1 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4677,6 +4677,9 @@ impl OpenCADStudio { match self.tabs[i].scene.document.get_entity(handles[0]) { Some(acadrust::EntityType::LwPolyline(p)) => p.vertices.len(), Some(acadrust::EntityType::Polyline2D(p)) => p.vertices.len(), + Some(acadrust::EntityType::Table(table)) => { + table.row_count().saturating_mul(table.column_count()) + } _ => 0, } } else { @@ -4688,6 +4691,7 @@ impl OpenCADStudio { let next = (cur + delta as i64).rem_euclid(n as i64) as usize; self.tabs[i].properties.prop_vertex = next; self.tabs[i].properties.prop_vertex_indicator_active = next != cur as usize; + crate::entities::table::set_prop_current_cell(next); self.refresh_properties(); } Task::none() @@ -4864,6 +4868,49 @@ impl OpenCADStudio { } return Task::none(); } + if matches!( + field.as_str(), + "tbl_cell_content_color" | "tbl_cell_background_color" + ) { + let cell_index = self.tabs[i].properties.prop_vertex; + if !handles.is_empty() { + self.push_undo_snapshot(i, "TABLE CELL COLOR"); + for &handle in &handles { + let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + else { + continue; + }; + let columns = table.column_count(); + if columns == 0 { + continue; + } + if let Some(cell) = table.cell_mut( + cell_index / columns, + cell_index % columns, + ) { + let style = cell.style.get_or_insert_with(Default::default); + if field == "tbl_cell_content_color" { + style.content_color = color; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::CONTENT_COLOR, + ); + } else { + style.background_color = color; + style.fill_enabled = true; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, + ); + } + } + } + self.invalidate_property_targets(i, &handles); + self.tabs[i].properties.open_color_field = None; + self.tabs[i].dirty = true; + self.refresh_properties(); + } + return Task::none(); + } if !handles.is_empty() { self.push_undo_snapshot(i, "CHPROP"); let idx = if field == "gradient_color_2" { 1 } else { 0 }; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 379eddbf..899a3139 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4391,6 +4391,95 @@ impl OpenCADStudio { ).as_ref()); return Task::none(); } + let table_editor = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + AcadEntityType::Table(table) => { + let horizontal = glam::DVec3::new( + table.horizontal_direction.x, + table.horizontal_direction.y, + table.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new( + table.normal.x, + table.normal.y, + table.normal.z, + ) + .normalize_or(glam::DVec3::Z); + let mut down = horizontal + .cross(normal) + .normalize_or(glam::DVec3::NEG_Y); + let flows_up = table + .table_style_handle + .and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + }) + .is_some_and(|object| { + matches!( + object, + acadrust::objects::ObjectType::TableStyle(style) + if matches!( + style.flow_direction, + acadrust::objects::TableFlowDirection::Up + ) + ) + }); + if flows_up { + down = -down; + } + let origin = glam::DVec3::new( + table.insertion_point.x, + table.insertion_point.y, + table.insertion_point.z, + ); + let relative = click_world - origin; + let x = relative.dot(horizontal); + let y = relative.dot(down); + if x < 0.0 || y < 0.0 { + return None; + } + let column = table + .columns + .iter() + .scan(0.0, |offset, column| { + *offset += column.width; + Some(*offset) + }) + .position(|end| x <= end)?; + let row = table + .rows + .iter() + .scan(0.0, |offset, row| { + *offset += row.height; + Some(*offset) + }) + .position(|end| y <= end)?; + Some(( + crate::modules::annotate::table_cmd::TableCellEditCommand::new( + handle, table, row, column, + ), + row * table.column_count() + column, + )) + } + _ => None, + }); + if let Some((command, cell_index)) = table_editor { + self.tabs[i].properties.prop_vertex = cell_index; + self.tabs[i].properties.prop_vertex_indicator_active = true; + crate::entities::table::set_prop_current_cell(cell_index); + self.refresh_properties(); + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Task::none(); + } // Any text-bearing entity opens its in-place editor // (plain box or rich MText editor, per type). A // Leader resolves to the entity it annotates. diff --git a/src/entities/table.rs b/src/entities/table.rs index 67acd76b..c967d0d6 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -13,6 +13,18 @@ use crate::scene::view::transform; use crate::scene::model::wire_model::SnapHint; use crate::t; +thread_local! { + static PROPERTY_CELL: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +pub fn set_prop_current_cell(index: usize) { + PROPERTY_CELL.with(|cell| cell.set(index)); +} + +fn prop_current_cell() -> usize { + PROPERTY_CELL.with(std::cell::Cell::get) +} + fn v3(v: &acadrust::types::Vector3) -> Vec3 { Vec3::new(v.x as f32, v.y as f32, v.z as f32) } @@ -227,6 +239,71 @@ fn format_cell_value(value: &acadrust::entities::table::CellValue) -> String { } } +fn table_cell_reference(reference: &str) -> Option<(usize, usize)> { + let reference = reference.trim(); + let split = reference + .char_indices() + .find(|(_, ch)| ch.is_ascii_digit())? + .0; + let (letters, digits) = reference.split_at(split); + if letters.is_empty() || digits.is_empty() { + return None; + } + let mut column = 0usize; + for ch in letters.chars() { + if !ch.is_ascii_alphabetic() { + return None; + } + column = column + .checked_mul(26)? + .checked_add(ch.to_ascii_uppercase() as usize - 'A' as usize + 1)?; + } + let row = digits.parse::().ok()?.checked_sub(1)?; + Some((row, column.checked_sub(1)?)) +} + +fn evaluate_table_formula(table: &Table, expression: &str) -> Option { + let body = expression.trim().strip_prefix('=')?.trim(); + if let Some((row, column)) = table_cell_reference(body) { + return table.cell(row, column).map(|cell| cell.text_value().to_string()); + } + let open = body.find('(')?; + let close = body.rfind(')')?; + let function = body[..open].trim().to_ascii_uppercase(); + let range = &body[open + 1..close]; + let (first, last) = range.split_once(':').unwrap_or((range, range)); + let (r1, c1) = table_cell_reference(first)?; + let (r2, c2) = table_cell_reference(last)?; + let mut values = Vec::new(); + for row in r1.min(r2)..=r1.max(r2) { + for column in c1.min(c2)..=c1.max(c2) { + if let Some(value) = table + .cell(row, column) + .and_then(|cell| cell.text_value().trim().parse::().ok()) + { + values.push(value); + } + } + } + match function.as_str() { + "SUM" => Some(values.iter().sum::().to_string()), + "AVERAGE" | "AVG" if !values.is_empty() => { + Some((values.iter().sum::() / values.len() as f64).to_string()) + } + "COUNT" => Some(values.len().to_string()), + "MIN" if !values.is_empty() => { + Some(values.into_iter().fold(f64::INFINITY, f64::min).to_string()) + } + "MAX" if !values.is_empty() => Some( + values + .into_iter() + .fold(f64::NEG_INFINITY, f64::max) + .to_string(), + ), + _ => None, + } +} + fn fallback_content_centers( bounds: [f32; 4], sizes: &[(f32, f32)], @@ -312,12 +389,12 @@ fn fallback_content_centers( fn content_display_value( document: &acadrust::CadDocument, - table_handle: acadrust::Handle, + table: &Table, content: &acadrust::entities::table::CellContent, ) -> String { if let Some(handle) = content.field_handle { if let Some(value) = - crate::entities::field::resolve_handle(document, handle, table_handle) + crate::entities::field::resolve_handle(document, handle, table.common.handle) { return value; } @@ -331,7 +408,8 @@ fn content_display_value( } } } - format_cell_value(&content.value) + let value = format_cell_value(&content.value); + evaluate_table_formula(table, &value).unwrap_or(value) } fn resolved_content_geometry( @@ -1326,8 +1404,7 @@ pub fn tessellate_table( .iter() .enumerate() .filter_map(|(index, content)| { - let text = - content_display_value(document, tab.common.handle, content); + let text = content_display_value(document, tab, content); (!text.is_empty()).then_some((index, content, text)) }) .collect(); @@ -1740,30 +1817,120 @@ pub fn tessellate_table( impl Grippable for Table { fn grips(&self) -> Vec { - vec![square_grip( + let origin = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let horizontal = glam::DVec3::new( + self.horizontal_direction.x, + self.horizontal_direction.y, + self.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) + .normalize_or(glam::DVec3::Z); + let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + let width = self.total_width(); + let height = self.total_height(); + let mut grips = vec![square_grip( 0, - glam::DVec3::new( - self.insertion_point.x, - self.insertion_point.y, - self.insertion_point.z, - ), - )] + origin, + )]; + grips.push(square_grip(1, origin + horizontal * width)); + grips.push(square_grip(2, origin + down * height)); + let mut offset = 0.0; + for (column, definition) in self.columns.iter().enumerate() { + offset += definition.width; + if column + 1 < self.columns.len() { + grips.push(square_grip( + 100 + column, + origin + horizontal * offset + down * (height * 0.5), + )); + } + } + offset = 0.0; + for (row, definition) in self.rows.iter().enumerate() { + offset += definition.height; + if row + 1 < self.rows.len() { + grips.push(square_grip( + 1000 + row, + origin + horizontal * (width * 0.5) + down * offset, + )); + } + } + for (index, data) in self.break_data.iter().enumerate() { + grips.push(square_grip( + 2000 + index, + origin + glam::DVec3::new(data.position.x, data.position.y, data.position.z), + )); + } + grips } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if let GripApply::Translate(delta) = apply { + if let Some(grip) = self.grips().into_iter().find(|grip| grip.id == grip_id) { + self.apply_grip(grip_id, GripApply::Absolute(grip.world + delta)); + } + return; + } + let GripApply::Absolute(point) = apply else { + return; + }; if grip_id == 0 { - match apply { - GripApply::Translate(d) => { - self.insertion_point.x += d.x as f64; - self.insertion_point.y += d.y as f64; - self.insertion_point.z += d.z as f64; + self.insertion_point.x = point.x; + self.insertion_point.y = point.y; + self.insertion_point.z = point.z; + return; + } + let origin = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let horizontal = glam::DVec3::new( + self.horizontal_direction.x, + self.horizontal_direction.y, + self.horizontal_direction.z, + ) + .normalize_or(glam::DVec3::X); + let normal = glam::DVec3::new(self.normal.x, self.normal.y, self.normal.z) + .normalize_or(glam::DVec3::Z); + let down = horizontal.cross(normal).normalize_or(glam::DVec3::NEG_Y); + if grip_id == 1 { + let width = (point - origin).dot(horizontal).max(1.0e-6); + let current = self.total_width(); + if current > 1.0e-12 { + for column in &mut self.columns { + column.width *= width / current; } - GripApply::Absolute(p) => { - self.insertion_point.x = p.x as f64; - self.insertion_point.y = p.y as f64; - self.insertion_point.z = p.z as f64; + } + } else if grip_id == 2 { + let height = (point - origin).dot(down).max(1.0e-6); + let current = self.total_height(); + if current > 1.0e-12 { + for row in &mut self.rows { + row.height *= height / current; } } + } else if (100..1000).contains(&grip_id) { + let column = grip_id - 100; + let before: f64 = self.columns.iter().take(column).map(|item| item.width).sum(); + if let Some(definition) = self.columns.get_mut(column) { + definition.width = ((point - origin).dot(horizontal) - before).max(1.0e-6); + } + } else if (1000..2000).contains(&grip_id) { + let row = grip_id - 1000; + let before: f64 = self.rows.iter().take(row).map(|item| item.height).sum(); + if let Some(definition) = self.rows.get_mut(row) { + definition.height = ((point - origin).dot(down) - before).max(1.0e-6); + } + } else if grip_id >= 2000 { + if let Some(data) = self.break_data.get_mut(grip_id - 2000) { + let offset = point - origin; + data.position = acadrust::types::Vector3::new(offset.x, offset.y, offset.z); + } } } } @@ -1772,13 +1939,6 @@ impl PropertyEditable for Table { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { use crate::entities::common::edit_prop as edit; use acadrust::entities::table::BreakOptionFlags; - - let fmt_h = |oh: &Option| -> String { - match oh { - Some(h) if !h.is_null() => format!("{:X}", h.value()), - _ => "(none)".to_string(), - } - }; let toggle = |label: &str, field: &'static str, b: bool| -> Property { Property { label: label.into(), @@ -1789,26 +1949,38 @@ impl PropertyEditable for Table { // Direction = angle of the horizontal direction vector in the XY plane. let direction_deg = (self.horizontal_direction.y.atan2(self.horizontal_direction.x)).to_degrees(); - let mut content_count = 0usize; - let mut block_content_count = 0usize; - let mut field_content_count = 0usize; - let mut linked_cell_count = 0usize; - for row in &self.rows { - for cell in &row.cells { - content_count += cell.contents.len(); - linked_cell_count += usize::from(cell.has_linked_data); - for content in &cell.contents { - block_content_count += usize::from(content.block_handle.is_some()); - field_content_count += usize::from(content.field_handle.is_some()); - } - } - } - let break_heights = self - .break_data - .iter() - .map(|data| format!("{:.4}", data.height)) - .collect::>() - .join(", "); + let break_height = self.break_data.first().map(|data| data.height).unwrap_or(0.0); + let cell_count = self.rows.len().saturating_mul(self.columns.len()); + let cell_index = prop_current_cell().min(cell_count.saturating_sub(1)); + let cell_row = if self.columns.is_empty() { + 0 + } else { + cell_index / self.columns.len() + }; + let cell_column = if self.columns.is_empty() { + 0 + } else { + cell_index % self.columns.len() + }; + let cell = self.cell(cell_row, cell_column); + let cell_style = cell.and_then(|cell| cell.style.as_ref()); + let alignment = match cell_style.map(|style| style.alignment).unwrap_or(5) { + 1 => "Top Left", + 2 => "Top Center", + 3 => "Top Right", + 4 => "Middle Left", + 6 => "Middle Right", + 7 => "Bottom Left", + 8 => "Bottom Center", + 9 => "Bottom Right", + _ => "Middle Center", + }; + let cell_locked = cell.is_some_and(|cell| { + use acadrust::entities::table::CellStateFlags; + cell.state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, + ) + }); vec![ PropSection { @@ -1817,42 +1989,125 @@ impl PropertyEditable for Table { ro( t!("Table style").as_ref(), "tbl_style_handle", - fmt_h(&self.table_style_handle), + "Standard", ), - ro(t!("Rows").as_ref(), "tbl_rows", self.rows.len().to_string()), - ro(t!("Columns").as_ref(), "tbl_cols", self.columns.len().to_string()), - ro(t!("Contents").as_ref(), "tbl_contents", content_count.to_string()), + edit(t!("Insertion X").as_ref(), "tbl_insert_x", self.insertion_point.x), + edit(t!("Insertion Y").as_ref(), "tbl_insert_y", self.insertion_point.y), + edit(t!("Insertion Z").as_ref(), "tbl_insert_z", self.insertion_point.z), + edit(t!("Direction").as_ref(), "tbl_direction", direction_deg), + edit(t!("Rows").as_ref(), "tbl_rows", self.rows.len() as f64), + edit(t!("Columns").as_ref(), "tbl_cols", self.columns.len() as f64), + edit(t!("Table width").as_ref(), "tbl_width", self.total_width()), + edit(t!("Table height").as_ref(), "tbl_height", self.total_height()), + ro(t!("Normal X").as_ref(), "tbl_normal_x", format!("{:.4}", self.normal.x)), + ro(t!("Normal Y").as_ref(), "tbl_normal_y", format!("{:.4}", self.normal.y)), + ro(t!("Normal Z").as_ref(), "tbl_normal_z", format!("{:.4}", self.normal.z)), + ], + }, + PropSection { + title: t!("Cell").into_owned(), + props: vec![ + Property { + label: t!("Current cell").into_owned(), + field: "tbl_current_cell", + value: PropValue::Stepper { + field: "tbl_current_cell", + display: format!( + "{},{} ({} / {})", + cell_row, + cell_column, + cell_index.saturating_add(1), + cell_count + ), + }, + }, + ro(t!("Row").as_ref(), "tbl_cell_row", cell_row.to_string()), ro( - t!("Block contents").as_ref(), - "tbl_block_contents", - block_content_count.to_string(), + t!("Column").as_ref(), + "tbl_cell_column", + cell_column.to_string(), ), - ro( - t!("Field contents").as_ref(), - "tbl_field_contents", - field_content_count.to_string(), + Property { + label: t!("Contents").into_owned(), + field: "tbl_cell_text", + value: PropValue::PlainText( + cell.map(|cell| cell.text_value().to_string()).unwrap_or_default(), + ), + }, + Property { + label: t!("Alignment").into_owned(), + field: "tbl_cell_alignment", + value: PropValue::Choice { + selected: alignment.to_string(), + options: vec![ + "Top Left".into(), + "Top Center".into(), + "Top Right".into(), + "Middle Left".into(), + "Middle Center".into(), + "Middle Right".into(), + "Bottom Left".into(), + "Bottom Center".into(), + "Bottom Right".into(), + ], + }, + }, + edit( + t!("Text height").as_ref(), + "tbl_cell_text_height", + cell_style.map(|style| style.text_height).unwrap_or(0.18), ), - ro( - t!("Merged ranges").as_ref(), - "tbl_merged_ranges", - self.merged_ranges.len().to_string(), + Property { + label: t!("Content color").into_owned(), + field: "tbl_cell_content_color", + value: PropValue::ColorChoice( + cell_style + .map(|style| style.content_color) + .unwrap_or(acadrust::types::Color::ByBlock), + ), + }, + Property { + label: t!("Background color").into_owned(), + field: "tbl_cell_background_color", + value: PropValue::ColorChoice( + cell_style + .map(|style| style.background_color) + .unwrap_or(acadrust::types::Color::ByBlock), + ), + }, + Property { + label: t!("Data format").into_owned(), + field: "tbl_cell_format", + value: PropValue::PlainText( + cell_style.map(|style| style.value_format.clone()).unwrap_or_default(), + ), + }, + toggle( + t!("Background fill").as_ref(), + "tbl_cell_fill", + cell_style.is_some_and(|style| style.fill_enabled), ), - ro( - t!("Linked cells").as_ref(), - "tbl_linked_cells", - linked_cell_count.to_string(), + toggle( + t!("Top border").as_ref(), + "tbl_cell_border_top", + cell_style.is_none_or(|style| !style.top_border.invisible), ), - ro(t!("Direction").as_ref(), "tbl_direction", format!("{:.4}", direction_deg)), - ro( - t!("Table width").as_ref(), - "tbl_width", - format!("{:.4}", self.total_width()), + toggle( + t!("Right border").as_ref(), + "tbl_cell_border_right", + cell_style.is_none_or(|style| !style.right_border.invisible), ), - ro( - t!("Table height").as_ref(), - "tbl_height", - format!("{:.4}", self.total_height()), + toggle( + t!("Bottom border").as_ref(), + "tbl_cell_border_bottom", + cell_style.is_none_or(|style| !style.bottom_border.invisible), ), + toggle( + t!("Left border").as_ref(), + "tbl_cell_border_left", + cell_style.is_none_or(|style| !style.left_border.invisible), + ), + toggle(t!("Locked").as_ref(), "tbl_cell_locked", cell_locked), ], }, PropSection { @@ -1863,11 +2118,19 @@ impl PropertyEditable for Table { "tbl_break_enabled", self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS), ), - ro( - t!("Direction").as_ref(), - "tbl_break_direction", - format!("{:?}", self.break_flow_direction), - ), + Property { + label: t!("Direction").into_owned(), + field: "tbl_break_direction", + value: PropValue::Choice { + selected: match self.break_flow_direction { + acadrust::entities::table::BreakFlowDirection::Right => "Right", + acadrust::entities::table::BreakFlowDirection::Left => "Left", + acadrust::entities::table::BreakFlowDirection::Vertical => "Down", + } + .to_string(), + options: vec!["Right".into(), "Left".into(), "Down".into()], + }, + }, toggle( t!("Repeat top labels").as_ref(), "tbl_break_repeat_top", @@ -1892,12 +2155,7 @@ impl PropertyEditable for Table { self.break_options .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS), ), - ro( - t!("Segments").as_ref(), - "tbl_break_segments", - self.break_ranges.len().max(self.break_data.len()).to_string(), - ), - ro(t!("Break heights").as_ref(), "tbl_break_height", break_heights), + edit(t!("Maximum height").as_ref(), "tbl_break_height", break_height), edit(t!("Spacing").as_ref(), "tbl_break_spacing", self.break_spacing), ], }, @@ -1924,11 +2182,198 @@ impl PropertyEditable for Table { self.break_options.set(flag, on); return; } - if field == "tbl_break_spacing" { - if let Some(v) = parse_f64(value) { - self.break_spacing = v; + let cell_index = prop_current_cell(); + let columns = self.columns.len(); + let cell_position = (columns > 0).then_some((cell_index / columns, cell_index % columns)); + if let Some((row, column)) = cell_position { + match field { + "tbl_cell_text" => { + if let Some(cell) = self.cell_mut(row, column) { + use acadrust::entities::table::CellStateFlags; + if !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) { + cell.set_text(value); + } + } + return; + } + "tbl_cell_alignment" => { + let alignment = match value.trim().to_ascii_uppercase().as_str() { + "TOP LEFT" => 1, + "TOP CENTER" => 2, + "TOP RIGHT" => 3, + "MIDDLE LEFT" => 4, + "MIDDLE RIGHT" => 6, + "BOTTOM LEFT" => 7, + "BOTTOM CENTER" => 8, + "BOTTOM RIGHT" => 9, + _ => 5, + }; + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(Default::default); + style.alignment = alignment; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::ALIGNMENT, + ); + } + return; + } + "tbl_cell_format" => { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(Default::default); + style.value_format = value.to_string(); + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::DATA_FORMAT, + ); + } + return; + } + "tbl_cell_fill" => { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(Default::default); + style.fill_enabled = if value == "toggle" { + !style.fill_enabled + } else { + value == "true" + }; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::BACKGROUND_COLOR, + ); + } + return; + } + "tbl_cell_border_top" + | "tbl_cell_border_right" + | "tbl_cell_border_bottom" + | "tbl_cell_border_left" => { + if let Some(cell) = self.cell_mut(row, column) { + use acadrust::entities::table::{ + BorderPropertyFlags, CellEdgeFlags, + }; + let style = cell.style.get_or_insert_with(Default::default); + let (border, edge) = match field { + "tbl_cell_border_top" => (&mut style.top_border, CellEdgeFlags::TOP), + "tbl_cell_border_right" => { + (&mut style.right_border, CellEdgeFlags::RIGHT) + } + "tbl_cell_border_bottom" => { + (&mut style.bottom_border, CellEdgeFlags::BOTTOM) + } + _ => (&mut style.left_border, CellEdgeFlags::LEFT), + }; + let visible = if value == "toggle" { + border.invisible + } else { + value == "true" + }; + border.invisible = !visible; + border.override_flags.insert(BorderPropertyFlags::INVISIBILITY); + style.applied_border_edges.insert(edge); + } + return; + } + "tbl_cell_locked" => { + if let Some(cell) = self.cell_mut(row, column) { + use acadrust::entities::table::CellStateFlags; + let locked = if value == "toggle" { + !cell.state.contains(CellStateFlags::CONTENT_LOCKED) + } else { + value == "true" + }; + cell.state.set(CellStateFlags::CONTENT_LOCKED, locked); + cell.state.set(CellStateFlags::FORMAT_LOCKED, locked); + } + return; + } + _ => {} } } + let Some(number) = parse_f64(value) else { + if field == "tbl_break_direction" { + self.break_flow_direction = match value.trim().to_ascii_uppercase().as_str() { + "LEFT" => acadrust::entities::table::BreakFlowDirection::Left, + "DOWN" | "VERTICAL" => { + acadrust::entities::table::BreakFlowDirection::Vertical + } + _ => acadrust::entities::table::BreakFlowDirection::Right, + }; + } + return; + }; + match field { + "tbl_insert_x" => self.insertion_point.x = number, + "tbl_insert_y" => self.insertion_point.y = number, + "tbl_insert_z" => self.insertion_point.z = number, + "tbl_direction" => { + let radians = number.to_radians(); + self.horizontal_direction.x = radians.cos(); + self.horizontal_direction.y = radians.sin(); + self.horizontal_direction.z = 0.0; + } + "tbl_rows" => { + let requested = number.round().max(1.0) as usize; + while self.rows.len() < requested { + self.add_row(); + } + while self.rows.len() > requested { + self.remove_row(self.rows.len() - 1); + } + } + "tbl_cols" => { + let requested = number.round().max(1.0) as usize; + let width = self.columns.last().map(|column| column.width).unwrap_or(2.0); + while self.columns.len() < requested { + self.add_column(width); + } + while self.columns.len() > requested { + self.remove_column(self.columns.len() - 1); + } + } + "tbl_width" if number > 0.0 => { + let current = self.total_width(); + if current > 1.0e-12 { + for column in &mut self.columns { + column.width *= number / current; + } + } + } + "tbl_height" if number > 0.0 => { + let current = self.total_height(); + if current > 1.0e-12 { + for row in &mut self.rows { + row.height *= number / current; + } + } + } + "tbl_cell_text_height" if number > 0.0 => { + if let Some((row, column)) = cell_position { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(Default::default); + style.text_height = number; + style.property_flags.insert( + acadrust::entities::table::CellStylePropertyFlags::TEXT_HEIGHT, + ); + } + } + } + "tbl_break_height" if number >= 0.0 => { + if self.break_data.is_empty() { + self.break_data.push(acadrust::entities::table::TableBreakData { + position: acadrust::types::Vector3::ZERO, + height: number, + flags: 0, + }); + } else { + for data in &mut self.break_data { + data.height = number; + } + } + } + "tbl_break_spacing" if number >= 0.0 => self.break_spacing = number, + _ => {} + } } } diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index a2e20e5b..e3e319b1 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -1,6 +1,11 @@ -// DATALINK command — manages data links to external spreadsheets. +// DATALINK command — manages persistent links to external tabular data. +use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::model::wire_model::WireModel; +use acadrust::entities::Table; +use acadrust::EntityType; +use glam::DVec3; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/data_link.svg")); @@ -12,3 +17,112 @@ pub fn tool() -> ToolDef { event: ModuleEvent::Command("DATALINK".to_string()), } } + +pub struct DataLinkPlaceCommand { + table: Table, + plane: WorkingPlane, +} + +impl DataLinkPlaceCommand { + pub fn new(mut table: Table, path: &str) -> Self { + table.name = format!("__OPENCAD_LINK_PENDING__{path}"); + Self { + table, + plane: WorkingPlane::default(), + } + } + + fn preview(&self, point: DVec3) -> WireModel { + let point = self.plane.to_local(point); + let mut points = Vec::new(); + let mut x = 0.0; + for column in 0..=self.table.column_count() { + if column > 0 { + x += self.table.columns[column - 1].width; + } + points.push(self.plane.to_world(point + DVec3::X * x).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(x, -self.table.total_height(), 0.0)) + .as_vec3() + .to_array(), + ); + } + let mut y = 0.0; + for row in 0..=self.table.row_count() { + if row > 0 { + y -= self.table.rows[row - 1].height; + } + points.push(self.plane.to_world(point + DVec3::Y * y).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(self.table.total_width(), y, 0.0)) + .as_vec3() + .to_array(), + ); + } + WireModel { + point_marker: None, + taper_widths: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: "data_link_preview".into(), + points, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } + } +} + +impl CadCommand for DataLinkPlaceCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + + fn name(&self) -> &'static str { + "DATALINK" + } + + fn prompt(&self) -> String { + "DATALINK Specify insertion point:".to_string() + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + let point = self.plane.to_local(point); + self.table.insertion_point = acadrust::types::Vector3::new(point.x, point.y, point.z); + CmdResult::CommitAndExit( + self.plane + .place_entity(EntityType::Table(self.table.clone())), + ) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + Some(self.preview(point)) + } +} diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index c243e2b6..1e9a1519 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -1,16 +1,14 @@ -// TABLE command — create an empty table entity. +// TABLE command — create a styled empty table. // -// Workflow: -// 1. Text: Enter number of columns (default 3) -// 2. Text: Enter number of rows (default 4, includes header row) -// 3. Point: Click insertion point -// -// Creates a Table entity with uniform row height (0.5) and column width (2.0). +// Title/header rows come from the selected table style and are not counted as +// data rows. The command remembers its previous settings and supports either a +// fixed insertion point or a two-corner sizing window. use acadrust::entities::TableBuilder; use acadrust::types::Vector3; use acadrust::EntityType; use glam::DVec3; +use std::sync::{Mutex, OnceLock}; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -29,22 +27,80 @@ pub fn tool() -> ToolDef { } const DEFAULT_COLS: usize = 3; -const DEFAULT_ROWS: usize = 4; -const COL_WIDTH: f64 = 2.0; -const ROW_HEIGHT: f64 = 0.5; +const DEFAULT_DATA_ROWS: usize = 4; +const DEFAULT_COL_WIDTH: f64 = 2.0; +const DEFAULT_ROW_HEIGHT: f64 = 0.5; +#[derive(Clone, Copy)] +enum InsertionMode { + Point, + Window, +} + +#[derive(Clone, Copy)] +struct TableDefaults { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + insertion_mode: InsertionMode, +} + +impl Default for TableDefaults { + fn default() -> Self { + Self { + columns: DEFAULT_COLS, + data_rows: DEFAULT_DATA_ROWS, + column_width: DEFAULT_COL_WIDTH, + row_height: DEFAULT_ROW_HEIGHT, + insertion_mode: InsertionMode::Point, + } + } +} + +fn saved_defaults() -> &'static Mutex { + static DEFAULTS: OnceLock> = OnceLock::new(); + DEFAULTS.get_or_init(|| Mutex::new(TableDefaults::default())) +} + +#[derive(Clone, Copy)] enum Step { Columns, - Rows { cols: usize }, - Insertion { cols: usize, rows: usize }, + DataRows { columns: usize }, + ColumnWidth { columns: usize, data_rows: usize }, + RowHeight { + columns: usize, + data_rows: usize, + column_width: f64, + }, + InsertionMode { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + }, + Insertion { + columns: usize, + data_rows: usize, + column_width: f64, + row_height: f64, + }, + WindowFirst { columns: usize, data_rows: usize }, + WindowSecond { + columns: usize, + data_rows: usize, + first: DVec3, + }, } pub struct TableCommand { step: Step, style_handle: Option, - column_width: f64, - row_height: f64, + suggested_column_width: f64, + suggested_row_height: f64, preview_scale: f64, + title_row: bool, + header_row: bool, plane: WorkingPlane, } @@ -53,9 +109,11 @@ impl TableCommand { Self { step: Step::Columns, style_handle: None, - column_width: COL_WIDTH, - row_height: ROW_HEIGHT, + suggested_column_width: DEFAULT_COL_WIDTH, + suggested_row_height: DEFAULT_ROW_HEIGHT, preview_scale: 1.0, + title_row: true, + header_row: true, plane: WorkingPlane::default(), } } @@ -71,21 +129,120 @@ impl TableCommand { .max(style.header_row_style.text_height) .max(style.title_row_style.text_height) .max(1.0e-6); - let row_height = text_height * 1.5 + style.vertical_margin * 2.0; - let column_width = text_height * 8.0 + style.horizontal_margin * 2.0; Self { step: Step::Columns, style_handle: Some(style_handle), - column_width, - row_height, + suggested_column_width: text_height * 8.0 + style.horizontal_margin * 2.0, + suggested_row_height: text_height * 1.5 + style.vertical_margin * 2.0, preview_scale: if style.annotative { annotation_multiplier } else { 1.0 }, + title_row: !style.title_suppressed, + header_row: !style.header_suppressed, plane: WorkingPlane::default(), } } + + fn defaults(&self) -> TableDefaults { + let mut defaults = saved_defaults().lock().map(|value| *value).unwrap_or_default(); + if (defaults.column_width - DEFAULT_COL_WIDTH).abs() < 1.0e-9 { + defaults.column_width = self.suggested_column_width; + } + if (defaults.row_height - DEFAULT_ROW_HEIGHT).abs() < 1.0e-9 { + defaults.row_height = self.suggested_row_height; + } + defaults + } + + fn total_rows(&self, data_rows: usize) -> usize { + data_rows + usize::from(self.title_row) + usize::from(self.header_row) + } + + fn build_table( + &self, + point: DVec3, + rows: usize, + columns: usize, + column_width: f64, + row_height: f64, + ) -> EntityType { + let point = self.plane.to_local(point); + let mut table = TableBuilder::new(rows, columns) + .at(Vector3::new(point.x, point.y, point.z)) + .row_height(row_height) + .column_width(column_width) + .build(); + table.table_style_handle = self.style_handle; + self.plane.place_entity(EntityType::Table(table)) + } + + fn preview_grid( + &self, + point: DVec3, + rows: usize, + columns: usize, + column_width: f64, + row_height: f64, + ) -> WireModel { + let point = self.plane.to_local(point); + let width = columns as f64 * column_width * self.preview_scale; + let height = rows as f64 * row_height * self.preview_scale; + let mut points = Vec::with_capacity((rows + columns + 2) * 2); + for column in 0..=columns { + let x = column as f64 * column_width * self.preview_scale; + points.push(self.plane.to_world(point + DVec3::X * x).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(x, -height, 0.0)) + .as_vec3() + .to_array(), + ); + } + for row in 0..=rows { + let y = -(row as f64 * row_height * self.preview_scale); + points.push(self.plane.to_world(point + DVec3::Y * y).as_vec3().to_array()); + points.push( + self.plane + .to_world(point + DVec3::new(width, y, 0.0)) + .as_vec3() + .to_array(), + ); + } + WireModel { + point_marker: None, + taper_widths: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: "table_preview".into(), + points, + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } + } } impl CadCommand for TableCommand { @@ -98,146 +255,278 @@ impl CadCommand for TableCommand { } fn prompt(&self) -> String { + let defaults = self.defaults(); match &self.step { Step::Columns => t!( "TABLE Enter number of columns [%{cols}]:", - cols = DEFAULT_COLS + cols = defaults.columns ) .into_owned(), - Step::Rows { cols } => t!( - "TABLE Enter number of rows (incl. header) [%{rows}] (%{cols} cols):", - rows = DEFAULT_ROWS, - cols = cols + Step::DataRows { columns } => t!( + "TABLE Enter number of data rows [%{rows}] (%{cols} cols):", + rows = defaults.data_rows, + cols = columns ) .into_owned(), - Step::Insertion { cols, rows } => t!( + Step::ColumnWidth { .. } => { + format!("TABLE Enter column width [{:.4}]:", defaults.column_width) + } + Step::RowHeight { .. } => { + format!("TABLE Enter row height [{:.4}]:", defaults.row_height) + } + Step::InsertionMode { .. } => format!( + "TABLE Insertion behavior [Point/Window] <{}>:", + match defaults.insertion_mode { + InsertionMode::Point => "Point", + InsertionMode::Window => "Window", + } + ), + Step::Insertion { columns, data_rows, .. } => t!( "TABLE Specify insertion point [%{cols}×%{rows}]:", - cols = cols, - rows = rows + cols = columns, + rows = self.total_rows(*data_rows) ) .into_owned(), + Step::WindowFirst { .. } => "TABLE Specify first corner:".to_string(), + Step::WindowSecond { .. } => "TABLE Specify opposite corner:".to_string(), } } fn wants_text_input(&self) -> bool { - !matches!(self.step, Step::Insertion { .. }) + matches!( + self.step, + Step::Columns + | Step::DataRows { .. } + | Step::ColumnWidth { .. } + | Step::RowHeight { .. } + | Step::InsertionMode { .. } + ) } fn on_text_input(&mut self, text: &str) -> Option { - let t = text.trim(); - match &self.step { + let input = text.trim(); + let defaults = self.defaults(); + match self.step { Step::Columns => { - let cols = if t.is_empty() { - DEFAULT_COLS + let columns = if input.is_empty() { + defaults.columns } else { - t.parse::().ok().filter(|&n| n >= 1)? + input.parse::().ok().filter(|value| *value > 0)? }; - self.step = Step::Rows { cols }; - Some(CmdResult::NeedPoint) + self.step = Step::DataRows { columns }; } - Step::Rows { cols } => { - let cols = *cols; - let rows = if t.is_empty() { - DEFAULT_ROWS + Step::DataRows { columns } => { + let data_rows = if input.is_empty() { + defaults.data_rows } else { - t.parse::().ok().filter(|&n| n >= 1)? + input.parse::().ok().filter(|value| *value > 0)? }; - self.step = Step::Insertion { cols, rows }; - Some(CmdResult::NeedPoint) + self.step = Step::ColumnWidth { columns, data_rows }; + } + Step::ColumnWidth { columns, data_rows } => { + let column_width = if input.is_empty() { + defaults.column_width + } else { + input.parse::().ok().filter(|value| *value > 0.0)? + }; + self.step = Step::RowHeight { + columns, + data_rows, + column_width, + }; + } + Step::RowHeight { columns, data_rows, column_width } => { + let row_height = if input.is_empty() { + defaults.row_height + } else { + input.parse::().ok().filter(|value| *value > 0.0)? + }; + self.step = Step::InsertionMode { + columns, + data_rows, + column_width, + row_height, + }; + } + Step::InsertionMode { columns, data_rows, column_width, row_height } => { + let mode = if input.is_empty() { + defaults.insertion_mode + } else if "POINT".starts_with(&input.to_ascii_uppercase()) { + InsertionMode::Point + } else if "WINDOW".starts_with(&input.to_ascii_uppercase()) { + InsertionMode::Window + } else { + return None; + }; + if let Ok(mut saved) = saved_defaults().lock() { + *saved = TableDefaults { + columns, + data_rows, + column_width, + row_height, + insertion_mode: mode, + }; + } + self.step = match mode { + InsertionMode::Point => Step::Insertion { + columns, + data_rows, + column_width, + row_height, + }, + InsertionMode::Window => Step::WindowFirst { columns, data_rows }, + }; + } + Step::Insertion { .. } | Step::WindowFirst { .. } | Step::WindowSecond { .. } => { + return None; } - Step::Insertion { .. } => None, } + Some(CmdResult::NeedPoint) } fn on_enter(&mut self) -> CmdResult { - match &self.step { - Step::Columns => { - // Accept default. - self.step = Step::Rows { cols: DEFAULT_COLS }; - CmdResult::NeedPoint - } - Step::Rows { cols } => { - let cols = *cols; - self.step = Step::Insertion { - cols, - rows: DEFAULT_ROWS, + self.on_text_input("") + .map_or(CmdResult::Cancel, |_| CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } => CmdResult::CommitAndExit(self.build_table( + point, + self.total_rows(data_rows), + columns, + column_width, + row_height, + )), + Step::WindowFirst { columns, data_rows } => { + self.step = Step::WindowSecond { + columns, + data_rows, + first: point, }; CmdResult::NeedPoint } - Step::Insertion { .. } => CmdResult::Cancel, + Step::WindowSecond { columns, data_rows, first } => { + let first = self.plane.to_local(first); + let second = self.plane.to_local(point); + let rows = self.total_rows(data_rows); + let width = (second.x - first.x).abs().max(1.0e-6); + let height = (second.y - first.y).abs().max(1.0e-6); + let top_left = DVec3::new(first.x.min(second.x), first.y.max(second.y), first.z); + CmdResult::CommitAndExit(self.build_table( + self.plane.to_world(top_left), + rows, + columns, + width / columns as f64, + height / rows as f64, + )) + } + _ => CmdResult::NeedPoint, } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::Insertion { cols, rows } = self.step { - let point = self.plane.to_local(pt); - let ins = Vector3::new(point.x, point.y, point.z); - let mut table = TableBuilder::new(rows, cols) - .at(ins) - .row_height(self.row_height) - .column_width(self.column_width) - .build(); - table.table_style_handle = self.style_handle; - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Table(table))) - } else { - CmdResult::NeedPoint - } - } - - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if let Step::Insertion { cols, rows } = self.step { - // Preview: outline of the table bounding box. - let w = (cols as f32) * (self.column_width * self.preview_scale) as f32; - let h = (rows as f32) * (self.row_height * self.preview_scale) as f32; - let point = self.plane.to_local(pt); - let corners = [ + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match self.step { + Step::Insertion { + columns, + data_rows, + column_width, + row_height, + } => Some(self.preview_grid( point, - point + DVec3::X * w as f64, - point + DVec3::new(w as f64, -(h as f64), 0.0), - point - DVec3::Y * h as f64, - ] - .map(|corner| self.plane.to_world(corner).as_vec3().to_array()); - let [p0, p1, p2, p3] = corners; - Some(WireModel { - point_marker: None, - taper_widths: Vec::new(), - world_width: 0.0, - depth_override: None, - display_visible: true, - plot_visible: true, - fill_is_3d: false, - fill_is_2d_solid: false, - render_instance: None, - pick_tris: Vec::new(), - pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: "table_preview".into(), - points: vec![ - p0, p1, p1, p2, p2, p3, p3, p0, - ], - points_low: Vec::new(), - color: WireModel::CYAN, - selected: false, - pattern_length: 0.0, - pattern: [0.0; 8], - line_weight_px: 1.0, - snap_pts: vec![], - tangent_geoms: vec![], - aci: 0, - key_vertices: vec![], - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }) - } else { - None + self.total_rows(data_rows), + columns, + column_width, + row_height, + )), + Step::WindowSecond { columns, data_rows, first } => { + let first = self.plane.to_local(first); + let second = self.plane.to_local(point); + let rows = self.total_rows(data_rows); + let width = (second.x - first.x).abs().max(1.0e-6); + let height = (second.y - first.y).abs().max(1.0e-6); + let top_left = DVec3::new(first.x.min(second.x), first.y.max(second.y), first.z); + Some(self.preview_grid( + self.plane.to_world(top_left), + rows, + columns, + width / columns as f64, + height / rows as f64, + )) + } + _ => None, } } } +inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["TABLE"] }); // TableCommand +pub struct TableCellEditCommand { + handle: acadrust::Handle, + table: acadrust::entities::Table, + row: usize, + column: usize, +} + +impl TableCellEditCommand { + pub fn new( + handle: acadrust::Handle, + table: &acadrust::entities::Table, + row: usize, + column: usize, + ) -> Self { + Self { + handle, + table: table.clone(), + row, + column, + } + } +} + +impl CadCommand for TableCellEditCommand { + fn name(&self) -> &'static str { + "TABLE CELL" + } + + fn prompt(&self) -> String { + let current = self.table.cell_text(self.row, self.column).unwrap_or(""); + format!( + "TABLE CELL Enter text for [{},{}] <{}>:", + self.row, self.column, current + ) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + use acadrust::entities::table::CellStateFlags; + let cell = self.table.cell_mut(self.row, self.column)?; + if cell + .state + .intersects(CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY) + { + return Some(CmdResult::Cancel); + } + cell.set_text(text); + Some(CmdResult::ReplaceMany( + vec![(self.handle, vec![EntityType::Table(self.table.clone())])], + Vec::new(), + )) + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } +} From 9dc4e08f7766ad515f60349bb50ae3ae6d65fb04 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 14:10:14 +0300 Subject: [PATCH 27/98] Align dimension property editability --- src/app/properties.rs | 21 +-------- src/entities/common.rs | 27 ++++++++++++ src/entities/dim_override.rs | 30 +++++++------ src/entities/dimension.rs | 82 +++++++++++++++++++++++++----------- 4 files changed, 105 insertions(+), 55 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index 7b5e481c..15672cd1 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2409,18 +2409,7 @@ fn set_row_value( /// The lineweight dropdown options (named defaults + the standard millimetre /// steps), matching the labels `dim_lineweight_label` produces. pub(crate) fn lineweight_options() -> Vec { - let mut v = vec![ - "ByLayer".to_string(), - "ByBlock".to_string(), - "Default".to_string(), - ]; - for lw in [ - 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, 140, 158, - 200, 211, - ] { - v.push(format!("{:.2} mm", lw as f64 / 100.0)); - } - v + crate::entities::common::lineweight_options() } /// Inverse of `dim_lineweight_label`: a lineweight label → DIMLWD enum value. @@ -2546,13 +2535,7 @@ fn leader_arrow_label( /// DIMLWD lineweight enum → label. fn dim_lineweight_label(dimlwd: i16) -> String { - match dimlwd { - -1 => "ByLayer".to_string(), - -2 => "ByBlock".to_string(), - -3 => "Default".to_string(), - v if v >= 0 => format!("{:.2} mm", v as f64 / 100.0), - _ => "Default".to_string(), - } + crate::entities::common::lineweight_label(dimlwd) } /// Human-readable INSUNITS name (DXF group 70 unit codes). diff --git a/src/entities/common.rs b/src/entities/common.rs index f8417b75..cf6bda3c 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -573,6 +573,33 @@ pub fn edit_scalar_prop(label: &str, field: &'static str, value: f64) -> Propert } } +/// Standard lineweight choices shared by entity-specific Properties rows. +pub fn lineweight_options() -> Vec { + let mut options = vec![ + "ByLayer".to_string(), + "ByBlock".to_string(), + "Default".to_string(), + ]; + for value in [ + 0, 5, 9, 13, 15, 18, 20, 25, 30, 35, 40, 50, 53, 60, 70, 80, 90, 100, 106, 120, + 140, 158, 200, 211, + ] { + options.push(format!("{:.2} mm", value as f64 / 100.0)); + } + options +} + +/// DIMLWD/DIMLWE lineweight value displayed by a Properties dropdown. +pub fn lineweight_label(value: i16) -> String { + match value { + -1 => "ByLayer".to_string(), + -2 => "ByBlock".to_string(), + -3 => "Default".to_string(), + value if value >= 0 => format!("{:.2} mm", value as f64 / 100.0), + _ => "Default".to_string(), + } +} + pub fn ro_prop(label: &str, field: &'static str, value: impl Into) -> Property { Property { label: label.into(), diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 68843073..f7bca09a 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -331,28 +331,34 @@ pub fn set_property( ) -> bool { let trimmed = value.trim(); let handle_field = match field { - "dim_arrowhead_1" => Some((DIMBLK1, true)), - "dim_arrowhead_2" => Some((DIMBLK2, true)), - "dim_linetype" => Some((DIMLTYPE, false)), - "dim_ext_linetype_1" => Some((DIMLTEX1, false)), - "dim_ext_linetype_2" => Some((DIMLTEX2, false)), + "dim_arrowhead_1" => Some(DIMBLK1), + "dim_arrowhead_2" => Some(DIMBLK2), + "dim_linetype" => Some(DIMLTYPE), + "dim_ext_linetype_1" => Some(DIMLTEX1), + "dim_ext_linetype_2" => Some(DIMLTEX2), + "dim_text_style" => Some(DIMTXSTY), _ => None, }; - if let Some((code, block)) = handle_field { - let resolved = if block { - (trimmed != "Closed filled") + if let Some(code) = handle_field { + let resolved = match field { + "dim_arrowhead_1" | "dim_arrowhead_2" => (trimmed != "Closed filled") .then(|| { doc.block_records .iter() .find(|record| record.name == trimmed) .map(|record| record.handle) }) - .flatten() - } else { - doc.line_types + .flatten(), + "dim_text_style" => doc + .text_styles + .iter() + .find(|style| style.name == trimmed) + .map(|style| style.handle), + _ => doc + .line_types .iter() .find(|line_type| line_type.name == trimmed) - .map(|line_type| line_type.handle) + .map(|line_type| line_type.handle), }; set(doc, handle, code, resolved.map(XDataValue::Handle)); return true; diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 2b1708e6..d5e9c4d1 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -8,7 +8,8 @@ use glam::{DVec3, Vec3}; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_angle_prop as edit_angle, edit_prop as edit, parse_f64, ro_prop as ro, square_grip, + center_grip, edit_angle_prop as edit_angle, edit_prop as edit, lineweight_label, + lineweight_options, parse_f64, ro_prop as ro, square_grip, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable}; use crate::scene::model::object::{GripApply, GripDef, PropSection}; @@ -1461,39 +1462,22 @@ pub fn style_sections( PropSection { title: t!("Lines & Arrows").into_owned(), props: vec![ - ro(t!("Arrow size").as_ref(), "dim_arrow_size", f(s.dimasz)), ro( - t!("Arrowhead 1").as_ref(), + t!("Arrow 1").as_ref(), "dim_arrowhead_1", block_name(document, s.dimblk1, &s.dimblk1_name), ), ro( - t!("Arrowhead 2").as_ref(), + t!("Arrow 2").as_ref(), "dim_arrowhead_2", block_name(document, s.dimblk2, &s.dimblk2_name), ), - ro( - t!("Dimension linetype").as_ref(), - "dim_linetype", - linetype_name(document, s.dimltex_handle), - ), - ro( - t!("Extension linetype 1").as_ref(), - "dim_ext_linetype_1", - linetype_name(document, s.dimltex1_handle), - ), - ro( - t!("Extension linetype 2").as_ref(), - "dim_ext_linetype_2", - linetype_name(document, s.dimltex2_handle), - ), - ro(t!("Dim line color").as_ref(), "dim_line_color", s.dimclrd.to_string()), + ro(t!("Arrow size").as_ref(), "dim_arrow_size", f(s.dimasz)), ro( t!("Dim line lineweight").as_ref(), "dim_line_lineweight", s.dimlwd.to_string(), ), - ro(t!("Ext line color").as_ref(), "dim_ext_line_color", s.dimclre.to_string()), ro( t!("Ext line lineweight").as_ref(), "dim_ext_line_lineweight", @@ -1501,17 +1485,34 @@ pub fn style_sections( ), ro(t!("Dim line 1").as_ref(), "dim_line_1", onoff(!s.dimsd1)), ro(t!("Dim line 2").as_ref(), "dim_line_2", onoff(!s.dimsd2)), + ro(t!("Dim line color").as_ref(), "dim_line_color", s.dimclrd.to_string()), + ro( + t!("Dim line linetype").as_ref(), + "dim_linetype", + linetype_name(document, s.dimltex_handle), + ), + ro(t!("Dim line ext").as_ref(), "dim_line_ext", f(s.dimdle)), + ro( + t!("Ext line 1 linetype").as_ref(), + "dim_ext_linetype_1", + linetype_name(document, s.dimltex1_handle), + ), + ro( + t!("Ext line 2 linetype").as_ref(), + "dim_ext_linetype_2", + linetype_name(document, s.dimltex2_handle), + ), ro(t!("Ext line 1").as_ref(), "dim_ext_line_1", onoff(!s.dimse1)), ro(t!("Ext line 2").as_ref(), "dim_ext_line_2", onoff(!s.dimse2)), - ro(t!("Dim line ext").as_ref(), "dim_line_ext", f(s.dimdle)), - ro(t!("Ext line ext").as_ref(), "dim_ext_line_ext", f(s.dimexe)), - ro(t!("Ext line offset").as_ref(), "dim_ext_line_offset", f(s.dimexo)), ro(t!("Ext line fixed").as_ref(), "dim_ext_line_fixed", yn(s.dimfxlon)), ro( t!("Ext line fixed length").as_ref(), "dim_ext_line_fixed_length", f(s.dimfxl), ), + ro(t!("Ext line color").as_ref(), "dim_ext_line_color", s.dimclre.to_string()), + ro(t!("Ext line ext").as_ref(), "dim_ext_line_ext", f(s.dimexe)), + ro(t!("Ext line offset").as_ref(), "dim_ext_line_offset", f(s.dimexo)), ], }, PropSection { @@ -1726,6 +1727,17 @@ pub fn style_sections( }, &["Bottom", "Middle", "Top"], ), + "dim_line_lineweight" | "dim_ext_line_lineweight" => { + let inherited = if property.field == "dim_line_lineweight" { + s.dimlwd + } else { + s.dimlwe + }; + crate::scene::model::object::PropValue::Choice { + selected: lineweight_label(number.unwrap_or(inherited)), + options: lineweight_options(), + } + } _ => crate::scene::model::object::PropValue::EditText(value), }; } @@ -1785,6 +1797,12 @@ pub fn style_sections( .map(|line_type| line_type.name.clone()) .filter(|name| !name.is_empty()) .collect(); + let text_style_options: Vec = document + .text_styles + .iter() + .map(|style| style.name.clone()) + .filter(|name| !name.is_empty()) + .collect(); for property in sections .iter_mut() .flat_map(|section| section.props.iter_mut()) @@ -1822,6 +1840,22 @@ pub fn style_sections( selected: current, options: linetype_options.clone(), }; + } else if property.field == "dim_text_style" { + if let Some(handle) = crate::entities::dim_override::handle( + data, + crate::entities::dim_override::DIMTXSTY, + ) { + current = document + .text_styles + .iter() + .find(|style| style.handle == handle) + .map(|style| style.name.clone()) + .unwrap_or(current); + } + property.value = crate::scene::model::object::PropValue::Choice { + selected: current, + options: text_style_options.clone(), + }; } } sections From bcd9a0dc32f1f77d5d893970d05a6413450b4c78 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:32:37 +0300 Subject: [PATCH 28/98] Align linear dimension general properties --- src/app/properties.rs | 55 +++++++++++++++++++++++------------- src/scene/dimension_assoc.rs | 22 +++++++++++++++ 2 files changed, 58 insertions(+), 19 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index 15672cd1..a2ed1ff1 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -981,6 +981,21 @@ impl OpenCADStudio { // Inject DimStyle picker + style-derived groups for Dimensions. if let acadrust::EntityType::Dimension(d) = entity { + if let Some(general) = sections.first_mut() { + general.props.retain(|property| property.field != "handle"); + let associative = + crate::scene::dimension_assoc::dimension_is_associative( + &self.tabs[i].scene.document, + d.base().common.handle, + ); + general.props.push(crate::scene::model::object::Property { + label: t!("Associative").into_owned(), + field: "associative", + value: crate::scene::model::object::PropValue::ReadOnly( + if associative { "Yes" } else { "No" }.to_string(), + ), + }); + } let dim_style_names: Vec = self.tabs[i] .scene .document @@ -1027,10 +1042,8 @@ impl OpenCADStudio { &self.tabs[i].scene.document, )); - // The style groups are a read-only mirror, but the - // dim-line colour is an editable per-object override: - // prefer the ACAD_DSTYLE code-176 (ACI) override, else - // the style's DIMCLRD. + // Prefer entity-level dimension-variable overrides; + // fall back to the assigned style values. use crate::entities::dim_override as dov; use crate::scene::model::object::PropValue; let dim_c = dov::color(&d.base().common.extended_data, dov::DIMCLRD) @@ -1043,11 +1056,6 @@ impl OpenCADStudio { for (field, code, inherited) in [ ("dim_ext_line_color", dov::DIMCLRE, style.dimclre), ("dim_text_color", dov::DIMCLRT, style.dimclrt), - ( - "dim_text_fill_color", - dov::DIMTFILLCLR, - style.dimtfillclr, - ), ] { let color = dov::color(&d.base().common.extended_data, code) .unwrap_or_else(|| { @@ -1321,10 +1329,8 @@ impl OpenCADStudio { } // Annotative Yes/No + a conditional "Annotative scale" row. - // Read-only: annotative state comes from the entity's style - // (or its own flag) and the assigned annotation-scale name(s) - // are walked from the entity's extension dictionary — both - // need the document, so they are resolved here. + // Annotative state and assigned scale names need the + // document, so they are resolved here. { // Which entities show an Annotative row, the field it uses, // and — for those that don't already carry the row @@ -1366,7 +1372,7 @@ impl OpenCADStudio { // (MTEXT via its native flag, single-line TEXT via the // context alone) get an editable toggle: turning it on // synthesizes a real per-scale representation. The - // remaining types are style-driven and stay read-only. + // remaining style-only types stay read-only. if anno_field == "annotative" { match entity { acadrust::EntityType::MText(t) => set_row_value( @@ -1379,7 +1385,8 @@ impl OpenCADStudio { ), acadrust::EntityType::Text(_) | acadrust::EntityType::Insert(_) - | acadrust::EntityType::Hatch(_) => set_row_value( + | acadrust::EntityType::Hatch(_) + | acadrust::EntityType::Dimension(_) => set_row_value( &mut sections, "annotative", crate::scene::model::object::PropValue::BoolToggle { @@ -1395,16 +1402,26 @@ impl OpenCADStudio { } } if is_anno { - // The applied annotation scale follows the current - // annotation scale (CANNOSCALE / the status-bar - // scale pill), not a per-object stored value. + let memberships = crate::scene::annotative::object_scale_memberships( + doc, + entity.common().handle, + ); + let assigned_scales = if memberships.is_empty() { + doc.header.current_annotation_scale.clone() + } else { + memberships + .into_iter() + .map(|(name, _)| name) + .collect::>() + .join(", ") + }; insert_row_after( &mut sections, anno_field, crate::entities::common::ro_prop( t!("Annotative scale").as_ref(), "annotative_scale", - doc.header.current_annotation_scale.clone(), + assigned_scales, ), ); } diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index ea91679f..75d9ef42 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -54,6 +54,28 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti .copied() } +pub(crate) fn dimension_is_associative( + document: &acadrust::CadDocument, + dimension: Handle, +) -> bool { + document.objects.values().any(|object| { + let ObjectType::Associative(object) = object else { + return false; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return false; + }; + association.dimension == dimension + && association.associativity != 0 + && association.references.iter().flatten().any(|reference| { + reference + .xrefs + .iter() + .any(|source| document.get_entity(*source).is_some()) + }) + }) +} + impl Scene { pub(crate) fn attach_linear_dimension_association( &mut self, From c66ec7fb64c53d66703e11fe7321da571940eb67 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:32:38 +0300 Subject: [PATCH 29/98] Complete linear dimension property behavior --- src/entities/dim_override.rs | 257 +++++- src/entities/dimension.rs | 1695 ++++++++++++++++++++++++---------- 2 files changed, 1419 insertions(+), 533 deletions(-) diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index f7bca09a..13ce51b8 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -77,7 +77,12 @@ pub const DIMATFIT: i16 = 289; pub const DIMFXLON: i16 = 290; pub const DIMTFILL: i16 = 69; pub const DIMTFILLCLR: i16 = 70; -pub const DIMTXTDIRECTION: i16 = 295; +pub const DIMTXTDIRECTION: i16 = 294; +pub const DIMALTMZF: i16 = 295; +pub const DIMALTMZS: i16 = 296; +pub const DIMMZF: i16 = 297; +pub const DIMMZS: i16 = 298; +pub const DIMTALN: i16 = 392; pub const DIMTXSTY: i16 = 340; pub const DIMBLK: i16 = 342; pub const DIMBLK1: i16 = 343; @@ -243,7 +248,9 @@ pub fn property_real_code(field: &str) -> Option { "dim_roundoff" => DIMRND, "dim_scale_linear" => DIMLFAC, "dim_alt_scale_factor" => DIMALTF, + "dim_alt_sub_units_scale" => DIMALTMZF, "dim_alt_roundoff" => DIMALTRND, + "dim_sub_units_scale" => DIMMZF, "dim_tolerance_limit_lower" => DIMTM, "dim_tolerance_limit_upper" => DIMTP, "dim_tolerance_text_height" => DIMTFAC, @@ -268,21 +275,23 @@ pub fn property_int_code(field: &str) -> Option { "dim_units" => DIMLUNIT, "dim_precision" => DIMDEC, "dim_decimal_separator" => DIMDSEP, - "dim_angle_format" => DIMAUNIT, - "dim_angle_precision" => DIMADEC, + "dim_fractional_type" => DIMFRAC, + "dim_text_view_direction" => DIMTXTDIRECTION, "dim_alt_enabled" => DIMALT, "dim_alt_format" => DIMALTU, "dim_alt_precision" => DIMALTD, + "dim_alt_tolerance_precision" => DIMALTTD, "dim_tolerance_precision" => DIMTDEC, "dim_tolerance_pos_vert" => DIMTOLJ, + "dim_tolerance_alignment" => DIMTALN, _ => return None, }) } pub fn property_string_code(field: &str) -> Option { Some(match field { - "dim_prefix_suffix" => DIMPOST, - "dim_alt_prefix_suffix" => DIMAPOST, + "dim_sub_units_suffix" => DIMMZS, + "dim_alt_sub_units_suffix" => DIMALTMZS, _ => return None, }) } @@ -317,12 +326,63 @@ fn inherited_int(doc: &CadDocument, handle: Handle, code: i16) -> i16 { DIMZIN => style.dimzin, DIMALTZ => style.dimaltz, DIMTZIN => style.dimtzin, + DIMALTTZ => style.dimalttz, DIMTOL => style.dimtol as i16, DIMLIM => style.dimlim as i16, + DIMALT => style.dimalt as i16, + DIMTFILL => style.dimtfill, + DIMTALN => 0, _ => 0, } } +fn inherited_real(doc: &CadDocument, handle: Handle, code: i16) -> f64 { + let Some(EntityType::Dimension(dimension)) = doc.get_entity(handle) else { + return 0.0; + }; + if let Some(value) = real(&dimension.base().common.extended_data, code) { + return value; + } + let Some(style) = doc.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(&dimension.base().style_name) + || (dimension.base().style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) else { + return 0.0; + }; + match code { + DIMGAP => style.dimgap, + DIMTP => style.dimtp, + DIMTM => style.dimtm, + _ => 0.0, + } +} + +fn inherited_string(doc: &CadDocument, handle: Handle, code: i16) -> String { + let Some(EntityType::Dimension(dimension)) = doc.get_entity(handle) else { + return String::new(); + }; + if let Some(value) = string(&dimension.base().common.extended_data, code) { + return value; + } + let Some(style) = doc.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(&dimension.base().style_name) + || (dimension.base().style_name.trim().is_empty() + && style.name.eq_ignore_ascii_case("Standard")) + }) else { + return String::new(); + }; + match code { + DIMPOST => style.dimpost.clone(), + DIMAPOST => style.dimapost.clone(), + _ => String::new(), + } +} + +fn split_template(value: &str) -> (&str, &str) { + value.split_once("<>").unwrap_or(("", value)) +} + pub fn set_property( doc: &mut CadDocument, handle: Handle, @@ -341,14 +401,16 @@ pub fn set_property( }; if let Some(code) = handle_field { let resolved = match field { - "dim_arrowhead_1" | "dim_arrowhead_2" => (trimmed != "Closed filled") - .then(|| { + "dim_arrowhead_1" | "dim_arrowhead_2" => { + if trimmed == "Closed filled" { + Some(Handle::NULL) + } else { doc.block_records .iter() .find(|record| record.name == trimmed) .map(|record| record.handle) - }) - .flatten(), + } + } "dim_text_style" => doc .text_styles .iter() @@ -360,7 +422,10 @@ pub fn set_property( .find(|line_type| line_type.name == trimmed) .map(|line_type| line_type.handle), }; - set(doc, handle, code, resolved.map(XDataValue::Handle)); + let Some(resolved) = resolved else { + return false; + }; + set(doc, handle, code, Some(XDataValue::Handle(resolved))); return true; } let inverse_boolean_code = match field { @@ -384,6 +449,8 @@ pub fn set_property( "dim_alt_suppress_trailing_zeros" => Some((DIMALTZ, 8)), "dim_tolerance_suppress_leading_zeros" => Some((DIMTZIN, 4)), "dim_tolerance_suppress_trailing_zeros" => Some((DIMTZIN, 8)), + "dim_alt_tolerance_suppress_leading_zeros" => Some((DIMALTTZ, 4)), + "dim_alt_tolerance_suppress_trailing_zeros" => Some((DIMALTTZ, 8)), _ => None, }; if let Some((code, bit)) = bit_field { @@ -399,21 +466,124 @@ pub fn set_property( set(doc, handle, code, Some(XDataValue::Integer16(value))); return true; } + let zero_base = match field { + "dim_suppress_zero_feet" => Some((DIMZIN, true)), + "dim_suppress_zero_inches" => Some((DIMZIN, false)), + "dim_alt_suppress_zero_feet" => Some((DIMALTZ, true)), + "dim_alt_suppress_zero_inches" => Some((DIMALTZ, false)), + "dim_tolerance_suppress_zero_feet" => Some((DIMTZIN, true)), + "dim_tolerance_suppress_zero_inches" => Some((DIMTZIN, false)), + "dim_alt_tolerance_suppress_zero_feet" => Some((DIMALTTZ, true)), + "dim_alt_tolerance_suppress_zero_inches" => Some((DIMALTTZ, false)), + _ => None, + }; + if let Some((code, feet_field)) = zero_base { + let Some(enabled) = yes(trimmed).map(|value| value != 0) else { + return false; + }; + let current = inherited_int(doc, handle, code); + let mut suppress_feet = matches!(current & 3, 0 | 3); + let mut suppress_inches = matches!(current & 3, 0 | 2); + if feet_field { + suppress_feet = enabled; + } else { + suppress_inches = enabled; + } + let base = match (suppress_feet, suppress_inches) { + (true, true) => 0, + (false, false) => 1, + (false, true) => 2, + (true, false) => 3, + }; + set( + doc, + handle, + code, + Some(XDataValue::Integer16((current & !3) | base)), + ); + return true; + } + if field == "dim_text_fill_color" { + let mode = match trimmed.to_ascii_lowercase().as_str() { + "none" => 0, + "background" => 1, + "color" => 2, + _ => return false, + }; + set(doc, handle, DIMTFILL, Some(XDataValue::Integer16(mode))); + return true; + } + if matches!( + field, + "dim_prefix" | "dim_suffix" | "dim_alt_prefix" | "dim_alt_suffix" + ) { + let code = if field.starts_with("dim_alt_") { + DIMAPOST + } else { + DIMPOST + }; + let current = inherited_string(doc, handle, code); + let (old_prefix, old_suffix) = split_template(¤t); + let (prefix, suffix) = if field.ends_with("prefix") { + (value, old_suffix) + } else { + (old_prefix, value) + }; + set( + doc, + handle, + code, + Some(XDataValue::String(format!("{prefix}<>{suffix}"))), + ); + return true; + } if field == "dim_tolerance_display" { - let (tolerance, limits) = match trimmed.to_ascii_lowercase().as_str() { - "deviation" => (1, 0), - "limits" => (0, 1), - "none" => (0, 0), + let current_gap = inherited_real(doc, handle, DIMGAP).abs(); + let upper = inherited_real(doc, handle, DIMTP); + let lower = inherited_real(doc, handle, DIMTM); + let (tolerance, limits, basic) = match trimmed.to_ascii_lowercase().as_str() { + "symmetrical" => { + set(doc, handle, DIMTM, Some(XDataValue::Real(upper))); + (1, 0, false) + } + "deviation" => { + if (upper - lower).abs() <= 1e-12 { + let distinct_lower = if upper.abs() > 1e-12 { 0.0 } else { 0.0001 }; + set( + doc, + handle, + DIMTM, + Some(XDataValue::Real(distinct_lower)), + ); + } + (1, 0, false) + } + "limits" => (0, 1, false), + "basic" => (0, 0, true), + "none" => (0, 0, false), _ => return false, }; set(doc, handle, DIMTOL, Some(XDataValue::Integer16(tolerance))); set(doc, handle, DIMLIM, Some(XDataValue::Integer16(limits))); + let gap = if basic { + -current_gap.max(0.0001) + } else { + current_gap + }; + set(doc, handle, DIMGAP, Some(XDataValue::Real(gap))); return true; } if let Some(code) = property_real_code(field) { if trimmed.is_empty() { set(doc, handle, code, None); } else if let Ok(number) = trimmed.parse::() { + let number = if field == "dim_text_offset" + && inherited_real(doc, handle, DIMGAP) < 0.0 + { + -number.abs() + } else { + number + }; set(doc, handle, code, Some(XDataValue::Real(number))); } else { return false; @@ -431,6 +601,9 @@ pub fn set_property( } if let Some(code) = property_int_code(field) { let parsed = match field { + "dim_line_lineweight" | "dim_ext_line_lineweight" => { + parse_lineweight_label(trimmed) + } "dim_ext_line_fixed" | "dim_text_outside_align" | "dim_text_inside_align" @@ -451,12 +624,15 @@ pub fn set_property( "desktop" => Some(6), _ => trimmed.parse().ok(), }, - "dim_angle_format" => match trimmed.to_ascii_lowercase().as_str() { - "decimal degrees" => Some(0), - "degrees/minutes/seconds" => Some(1), - "gradians" => Some(2), - "radians" => Some(3), - "surveyors units" => Some(4), + "dim_fractional_type" => match trimmed.to_ascii_lowercase().as_str() { + "horizontal" => Some(0), + "diagonal" => Some(1), + "not stacked" => Some(2), + _ => trimmed.parse().ok(), + }, + "dim_text_view_direction" => match trimmed.to_ascii_lowercase().as_str() { + "left-to-right" => Some(0), + "right-to-left" => Some(1), _ => trimmed.parse().ok(), }, "dim_text_pos_vert" => match trimmed.to_ascii_lowercase().as_str() { @@ -476,17 +652,27 @@ pub fn set_property( _ => trimmed.parse().ok(), }, "dim_fit" => match trimmed.to_ascii_lowercase().as_str() { - "either text or arrows" => Some(0), + "both text and arrows" => Some(0), "arrows" => Some(1), "text" => Some(2), - "both text and arrows" => Some(3), - "always keep text" => Some(4), + "best fit" => Some(3), _ => trimmed.parse().ok(), }, "dim_text_movement" => match trimmed.to_ascii_lowercase().as_str() { - "move dimension line" => Some(0), - "add leader" => Some(1), - "move text freely" => Some(2), + "keep dim line with text" => Some(0), + "move text, add leader" => Some(1), + "move text, no leader" => Some(2), + _ => trimmed.parse().ok(), + }, + "dim_alt_format" => match trimmed.to_ascii_lowercase().as_str() { + "scientific" => Some(1), + "decimal" => Some(2), + "engineering" => Some(3), + "architectural stacked" => Some(4), + "fractional stacked" => Some(5), + "architectural" => Some(6), + "fractional" => Some(7), + "desktop" => Some(8), _ => trimmed.parse().ok(), }, "dim_tolerance_pos_vert" => match trimmed.to_ascii_lowercase().as_str() { @@ -495,6 +681,11 @@ pub fn set_property( "top" => Some(2), _ => trimmed.parse().ok(), }, + "dim_tolerance_alignment" => match trimmed.to_ascii_lowercase().as_str() { + "align decimal separators" => Some(0), + "align operational symbols" => Some(1), + _ => trimmed.parse().ok(), + }, _ => trimmed.parse().ok(), }; let Some(number) = parsed else { @@ -505,3 +696,17 @@ pub fn set_property( } false } + +fn parse_lineweight_label(value: &str) -> Option { + match value.trim() { + "ByLayer" => Some(-1), + "ByBlock" => Some(-2), + "Default" => Some(-3), + value => value + .trim_end_matches("mm") + .trim() + .parse::() + .ok() + .map(|millimetres| (millimetres * 100.0).round() as i16), + } +} diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index d5e9c4d1..d95fa311 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -12,7 +12,7 @@ use crate::entities::common::{ lineweight_options, parse_f64, ro_prop as ro, square_grip, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable}; -use crate::scene::model::object::{GripApply, GripDef, PropSection}; +use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::t; fn base_props(base: &DimensionBase) -> Vec { @@ -73,23 +73,6 @@ fn properties(dim: &Dimension) -> Vec { base.style_name.clone(), ), }, - ro( - t!("Measurement").as_ref(), - "measurement", - format!("{:.4}", d.measurement()), - ), - crate::scene::model::object::Property { - label: t!("Text override").into_owned(), - field: "text_override", - value: crate::scene::model::object::PropValue::PlainText( - base.text_override().unwrap_or("").to_string(), - ), - }, - edit_angle( - t!("Text rotation").as_ref(), - "text_rotation", - base.text_rotation.to_degrees(), - ), edit_angle(t!("Dim line angle").as_ref(), "rotation", d.rotation.to_degrees()), edit_angle( t!("Extension line angle").as_ref(), @@ -1429,360 +1412,135 @@ use acadrust::entities::{MText, Text}; use acadrust::tables::DimStyle; use acadrust::types::{Color as AcadColor, Vector3}; -/// Dimension-style-derived property groups (Lines & Arrows, Text, Fit, Primary -/// Units, Alternate Units, Tolerances) built from the resolved DimStyle. These -/// are read-only mirrors of the style's dimension variables, injected by the -/// panel after the entity's own Misc/Geometry groups. +/// Build the linear-dimension property groups from the assigned style plus +/// any entity-level DSTYLE overrides. Conditional rows remain visible but are +/// read-only while their controlling option is disabled. pub fn style_sections( style: &DimStyle, dimension: &Dimension, document: &CadDocument, -) -> Vec { - let s = style; - let yn = |b: bool| if b { "Yes" } else { "No" }; - let onoff = |b: bool| if b { "On" } else { "Off" }; - let f = |v: f64| format!("{v:.4}"); - let dsep = { - let c = s.dimdsep as u8 as char; - if s.dimdsep > 0 && !c.is_control() { - c.to_string() - } else { - s.dimdsep.to_string() - } +) -> Vec { + use crate::entities::dim_override as ov; + + let data = &dimension.base().common.extended_data; + let real = |code, inherited| ov::real(data, code).unwrap_or(inherited); + let int = |code, inherited| ov::int(data, code).unwrap_or(inherited); + let string = |code, inherited: &str| { + ov::string(data, code).unwrap_or_else(|| inherited.to_string()) }; - let tol_display = if s.dimlim { + let on = |value: bool| if value { "On" } else { "Off" }; + let yes = |value: bool| if value { "Yes" } else { "No" }; + let number = |label: &str, field: &'static str, value: f64, editable: bool| { + property( + label, + field, + if editable { + PropValue::EditText(format!("{value:.4}")) + } else { + PropValue::ReadOnly(format!("{value:.4}")) + }, + ) + }; + let text = |label: &str, field: &'static str, value: String, editable: bool| { + property( + label, + field, + if editable { + PropValue::PlainText(value) + } else { + PropValue::ReadOnly(value) + }, + ) + }; + let choice = |label: &str, + field: &'static str, + selected: &str, + options: &[&str], + editable: bool| { + property( + label, + field, + if editable { + choice_value(selected, options) + } else { + PropValue::ReadOnly(selected.to_string()) + }, + ) + }; + + let s = style; + let dimfxlon = int(ov::DIMFXLON, s.dimfxlon as i16) != 0; + let dimlunit = int(ov::DIMLUNIT, s.dimlunit); + let dimfrac = int(ov::DIMFRAC, s.dimfrac); + let dimalt = int(ov::DIMALT, s.dimalt as i16) != 0; + let dimtol = int(ov::DIMTOL, s.dimtol as i16) != 0; + let dimlim = int(ov::DIMLIM, s.dimlim as i16) != 0; + let dimtp = real(ov::DIMTP, s.dimtp); + let dimtm = real(ov::DIMTM, s.dimtm); + let dimgap = real(ov::DIMGAP, s.dimgap); + let tolerance_display = if dimgap < 0.0 { + "Basic" + } else if dimlim { "Limits" - } else if s.dimtol { + } else if dimtol && (dimtp - dimtm).abs() <= 1e-12 { + "Symmetrical" + } else if dimtol { "Deviation" } else { "None" }; + let tolerance_enabled = matches!( + tolerance_display, + "Symmetrical" | "Deviation" | "Limits" + ); + let alternate_tolerance_enabled = tolerance_enabled && dimalt; + let dimzin = int(ov::DIMZIN, s.dimzin); + let dimaltz = int(ov::DIMALTZ, s.dimaltz); + let dimtzin = int(ov::DIMTZIN, s.dimtzin); + let dimalttz = int(ov::DIMALTTZ, s.dimalttz); + let annotative = s.annotative + || !crate::scene::annotative::object_scale_memberships( + document, + dimension.base().common.handle, + ) + .is_empty(); - let mut sections = vec![ - PropSection { - title: t!("Lines & Arrows").into_owned(), - props: vec![ - ro( - t!("Arrow 1").as_ref(), - "dim_arrowhead_1", - block_name(document, s.dimblk1, &s.dimblk1_name), - ), - ro( - t!("Arrow 2").as_ref(), - "dim_arrowhead_2", - block_name(document, s.dimblk2, &s.dimblk2_name), - ), - ro(t!("Arrow size").as_ref(), "dim_arrow_size", f(s.dimasz)), - ro( - t!("Dim line lineweight").as_ref(), - "dim_line_lineweight", - s.dimlwd.to_string(), - ), - ro( - t!("Ext line lineweight").as_ref(), - "dim_ext_line_lineweight", - s.dimlwe.to_string(), - ), - ro(t!("Dim line 1").as_ref(), "dim_line_1", onoff(!s.dimsd1)), - ro(t!("Dim line 2").as_ref(), "dim_line_2", onoff(!s.dimsd2)), - ro(t!("Dim line color").as_ref(), "dim_line_color", s.dimclrd.to_string()), - ro( - t!("Dim line linetype").as_ref(), - "dim_linetype", - linetype_name(document, s.dimltex_handle), - ), - ro(t!("Dim line ext").as_ref(), "dim_line_ext", f(s.dimdle)), - ro( - t!("Ext line 1 linetype").as_ref(), - "dim_ext_linetype_1", - linetype_name(document, s.dimltex1_handle), - ), - ro( - t!("Ext line 2 linetype").as_ref(), - "dim_ext_linetype_2", - linetype_name(document, s.dimltex2_handle), - ), - ro(t!("Ext line 1").as_ref(), "dim_ext_line_1", onoff(!s.dimse1)), - ro(t!("Ext line 2").as_ref(), "dim_ext_line_2", onoff(!s.dimse2)), - ro(t!("Ext line fixed").as_ref(), "dim_ext_line_fixed", yn(s.dimfxlon)), - ro( - t!("Ext line fixed length").as_ref(), - "dim_ext_line_fixed_length", - f(s.dimfxl), - ), - ro(t!("Ext line color").as_ref(), "dim_ext_line_color", s.dimclre.to_string()), - ro(t!("Ext line ext").as_ref(), "dim_ext_line_ext", f(s.dimexe)), - ro(t!("Ext line offset").as_ref(), "dim_ext_line_offset", f(s.dimexo)), - ], - }, - PropSection { - title: t!("Text").into_owned(), - props: vec![ - ro(t!("Fill color").as_ref(), "dim_text_fill_color", s.dimtfillclr.to_string()), - ro(t!("Text color").as_ref(), "dim_text_color", s.dimclrt.to_string()), - ro(t!("Text height").as_ref(), "dim_text_height", f(s.dimtxt)), - ro(t!("Text offset").as_ref(), "dim_text_offset", f(s.dimgap)), - ro(t!("Text pos vert").as_ref(), "dim_text_pos_vert", s.dimtad.to_string()), - ro(t!("Text pos hor").as_ref(), "dim_text_pos_hor", s.dimjust.to_string()), - ro(t!("Text outside align").as_ref(), "dim_text_outside_align", yn(s.dimtoh)), - ro(t!("Text inside align").as_ref(), "dim_text_inside_align", yn(s.dimtih)), - ro(t!("Text style").as_ref(), "dim_text_style", s.dimtxsty.clone()), - ], - }, - PropSection { - title: t!("Fit").into_owned(), - props: vec![ - ro(t!("Fit").as_ref(), "dim_fit", s.dimatfit.to_string()), - ro(t!("Text inside").as_ref(), "dim_text_inside", yn(s.dimtix)), - ro(t!("Text movement").as_ref(), "dim_text_movement", s.dimtmove.to_string()), - ro(t!("Dim scale overall").as_ref(), "dim_scale_overall", f(s.dimscale)), - ro(t!("Dim line forced").as_ref(), "dim_line_forced", yn(s.dimtofl)), - ro(t!("Dim line inside").as_ref(), "dim_line_inside", yn(s.dimsoxd)), - ], - }, - PropSection { - title: t!("Primary Units").into_owned(), - props: vec![ - ro(t!("Dim units").as_ref(), "dim_units", s.dimlunit.to_string()), - ro(t!("Precision").as_ref(), "dim_precision", s.dimdec.to_string()), - ro(t!("Decimal separator").as_ref(), "dim_decimal_separator", dsep), - ro(t!("Dim prefix/suffix").as_ref(), "dim_prefix_suffix", s.dimpost.clone()), - ro(t!("Dim roundoff").as_ref(), "dim_roundoff", f(s.dimrnd)), - ro( - t!("Suppress leading zeros").as_ref(), - "dim_suppress_leading_zeros", - yn(s.dimzin & 4 != 0), - ), - ro( - t!("Suppress trailing zeros").as_ref(), - "dim_suppress_trailing_zeros", - yn(s.dimzin & 8 != 0), - ), - ro(t!("Dim scale linear").as_ref(), "dim_scale_linear", f(s.dimlfac)), - ro(t!("Angle format").as_ref(), "dim_angle_format", s.dimaunit.to_string()), - ro(t!("Angle precision").as_ref(), "dim_angle_precision", s.dimadec.to_string()), - ], - }, - PropSection { - title: t!("Alternate Units").into_owned(), - props: vec![ - ro(t!("Alt enabled").as_ref(), "dim_alt_enabled", yn(s.dimalt)), - ro(t!("Alt format").as_ref(), "dim_alt_format", s.dimaltu.to_string()), - ro(t!("Alt precision").as_ref(), "dim_alt_precision", s.dimaltd.to_string()), - ro(t!("Alt scale factor").as_ref(), "dim_alt_scale_factor", f(s.dimaltf)), - ro(t!("Alt roundoff").as_ref(), "dim_alt_roundoff", f(s.dimaltrnd)), - ro(t!("Alt prefix/suffix").as_ref(), "dim_alt_prefix_suffix", s.dimapost.clone()), - ro( - t!("Alt suppress leading zeros").as_ref(), - "dim_alt_suppress_leading_zeros", - yn(s.dimaltz & 4 != 0), - ), - ro( - t!("Alt suppress trailing zeros").as_ref(), - "dim_alt_suppress_trailing_zeros", - yn(s.dimaltz & 8 != 0), - ), - ], - }, - PropSection { - title: t!("Tolerances").into_owned(), - props: vec![ - ro(t!("Tolerance display").as_ref(), "dim_tolerance_display", tol_display), - ro(t!("Tolerance limit lower").as_ref(), "dim_tolerance_limit_lower", f(s.dimtm)), - ro(t!("Tolerance limit upper").as_ref(), "dim_tolerance_limit_upper", f(s.dimtp)), - ro( - t!("Tolerance precision").as_ref(), - "dim_tolerance_precision", - s.dimtdec.to_string(), - ), - ro( - t!("Tolerance text height").as_ref(), - "dim_tolerance_text_height", - f(s.dimtfac), - ), - ro( - t!("Tolerance pos vert").as_ref(), - "dim_tolerance_pos_vert", - s.dimtolj.to_string(), - ), - ro( - t!("Tolerance suppress leading zeros").as_ref(), - "dim_tolerance_suppress_leading_zeros", - yn(s.dimtzin & 4 != 0), - ), - ro( - t!("Tolerance suppress trailing zeros").as_ref(), - "dim_tolerance_suppress_trailing_zeros", - yn(s.dimtzin & 8 != 0), - ), - ], - }, - ]; + let overridden_text_style = ov::handle(data, ov::DIMTXSTY); + let text_style_name = overridden_text_style + .and_then(|handle| { + document + .text_styles + .iter() + .find(|record| record.handle == handle) + .map(|record| record.name.clone()) + }) + .unwrap_or_else(|| s.dimtxsty.clone()); + let text_height_editable = document + .text_styles + .iter() + .find(|record| { + overridden_text_style + .is_some_and(|handle| record.handle == handle) + || (overridden_text_style.is_none() + && record.name.eq_ignore_ascii_case(&text_style_name)) + }) + .is_none_or(|record| !record.has_fixed_height()); - let data = &dimension.base().common.extended_data; - let yes_no = |value: bool| if value { "Yes" } else { "No" }.to_string(); - for property in sections - .iter_mut() - .flat_map(|section| section.props.iter_mut()) - { - let inherited = match &property.value { - crate::scene::model::object::PropValue::ReadOnly(value) => value.clone(), - _ => continue, - }; - if let Some(code) = crate::entities::dim_override::property_real_code(property.field) { - let value = crate::entities::dim_override::real(data, code) - .map(|number| number.to_string()) - .unwrap_or(inherited); - property.value = crate::scene::model::object::PropValue::EditText(value); - continue; + let (dim_prefix, dim_suffix) = + split_measurement_template(&string(ov::DIMPOST, &s.dimpost)); + let (alt_prefix, alt_suffix) = + split_measurement_template(&string(ov::DIMAPOST, &s.dimapost)); + let decimal_separator = { + let value = int(ov::DIMDSEP, s.dimdsep); + let character = value as u8 as char; + if value > 0 && !character.is_control() { + character.to_string() + } else { + value.to_string() } - if let Some(code) = crate::entities::dim_override::property_string_code(property.field) { - let value = crate::entities::dim_override::string(data, code).unwrap_or(inherited); - property.value = crate::scene::model::object::PropValue::PlainText(value); - continue; - } - if let Some(code) = crate::entities::dim_override::property_int_code(property.field) { - let number = crate::entities::dim_override::int(data, code); - let value = number.map(|number| number.to_string()).unwrap_or(inherited); - property.value = match property.field { - "dim_ext_line_fixed" - | "dim_text_outside_align" - | "dim_text_inside_align" - | "dim_text_inside" - | "dim_line_forced" - | "dim_line_inside" - | "dim_alt_enabled" => crate::scene::model::object::PropValue::Choice { - selected: number.map(|number| yes_no(number != 0)).unwrap_or(value), - options: vec!["Yes".to_string(), "No".to_string()], - }, - "dim_units" => crate::scene::model::object::PropValue::Choice { - selected: linear_unit_label(number.unwrap_or(s.dimlunit)).to_string(), - options: vec![ - "Scientific", "Decimal", "Engineering", "Architectural", - "Fractional", "Desktop", - ] - .into_iter() - .map(str::to_string) - .collect(), - }, - "dim_angle_format" => crate::scene::model::object::PropValue::Choice { - selected: angle_unit_label(number.unwrap_or(s.dimaunit)).to_string(), - options: vec![ - "Decimal degrees", "Degrees/minutes/seconds", "Gradians", - "Radians", "Surveyors units", - ] - .into_iter() - .map(str::to_string) - .collect(), - }, - "dim_text_pos_vert" => choice_value( - match number.unwrap_or(s.dimtad) { - 1 => "Above", - 2 => "Outside", - 3 => "JIS", - 4 => "Below", - _ => "Centered", - }, - &["Centered", "Above", "Outside", "JIS", "Below"], - ), - "dim_text_pos_hor" => choice_value( - match number.unwrap_or(s.dimjust) { - 1 => "At extension line 1", - 2 => "At extension line 2", - 3 => "Over extension line 1", - 4 => "Over extension line 2", - _ => "Centered", - }, - &[ - "Centered", "At extension line 1", "At extension line 2", - "Over extension line 1", "Over extension line 2", - ], - ), - "dim_fit" => choice_value( - match number.unwrap_or(s.dimatfit) { - 1 => "Arrows", - 2 => "Text", - 3 => "Both text and arrows", - 4 => "Always keep text", - _ => "Either text or arrows", - }, - &[ - "Either text or arrows", "Arrows", "Text", - "Both text and arrows", "Always keep text", - ], - ), - "dim_text_movement" => choice_value( - match number.unwrap_or(s.dimtmove) { - 1 => "Add leader", - 2 => "Move text freely", - _ => "Move dimension line", - }, - &["Move dimension line", "Add leader", "Move text freely"], - ), - "dim_tolerance_pos_vert" => choice_value( - match number.unwrap_or(s.dimtolj) { - 1 => "Middle", - 2 => "Top", - _ => "Bottom", - }, - &["Bottom", "Middle", "Top"], - ), - "dim_line_lineweight" | "dim_ext_line_lineweight" => { - let inherited = if property.field == "dim_line_lineweight" { - s.dimlwd - } else { - s.dimlwe - }; - crate::scene::model::object::PropValue::Choice { - selected: lineweight_label(number.unwrap_or(inherited)), - options: lineweight_options(), - } - } - _ => crate::scene::model::object::PropValue::EditText(value), - }; - } - } - - let integer_or = |code, inherited| { - crate::entities::dim_override::int(data, code).unwrap_or(inherited) }; - for property in sections - .iter_mut() - .flat_map(|section| section.props.iter_mut()) - { - let selected = match property.field { - "dim_line_1" => Some(if integer_or(crate::entities::dim_override::DIMSD1, s.dimsd1 as i16) == 0 { "On" } else { "Off" }), - "dim_line_2" => Some(if integer_or(crate::entities::dim_override::DIMSD2, s.dimsd2 as i16) == 0 { "On" } else { "Off" }), - "dim_ext_line_1" => Some(if integer_or(crate::entities::dim_override::DIMSE1, s.dimse1 as i16) == 0 { "On" } else { "Off" }), - "dim_ext_line_2" => Some(if integer_or(crate::entities::dim_override::DIMSE2, s.dimse2 as i16) == 0 { "On" } else { "Off" }), - "dim_suppress_leading_zeros" => Some(if integer_or(crate::entities::dim_override::DIMZIN, s.dimzin) & 4 != 0 { "Yes" } else { "No" }), - "dim_suppress_trailing_zeros" => Some(if integer_or(crate::entities::dim_override::DIMZIN, s.dimzin) & 8 != 0 { "Yes" } else { "No" }), - "dim_alt_suppress_leading_zeros" => Some(if integer_or(crate::entities::dim_override::DIMALTZ, s.dimaltz) & 4 != 0 { "Yes" } else { "No" }), - "dim_alt_suppress_trailing_zeros" => Some(if integer_or(crate::entities::dim_override::DIMALTZ, s.dimaltz) & 8 != 0 { "Yes" } else { "No" }), - "dim_tolerance_suppress_leading_zeros" => Some(if integer_or(crate::entities::dim_override::DIMTZIN, s.dimtzin) & 4 != 0 { "Yes" } else { "No" }), - "dim_tolerance_suppress_trailing_zeros" => Some(if integer_or(crate::entities::dim_override::DIMTZIN, s.dimtzin) & 8 != 0 { "Yes" } else { "No" }), - _ => None, - }; - if let Some(selected) = selected { - let options = if matches!( - property.field, - "dim_line_1" | "dim_line_2" | "dim_ext_line_1" | "dim_ext_line_2" - ) { - &["On", "Off"][..] - } else { - &["Yes", "No"][..] - }; - property.value = choice_value(selected, options); - } - if property.field == "dim_tolerance_display" { - let limits = integer_or(crate::entities::dim_override::DIMLIM, s.dimlim as i16) != 0; - let tolerance = integer_or(crate::entities::dim_override::DIMTOL, s.dimtol as i16) != 0; - let selected = if limits { "Limits" } else if tolerance { "Deviation" } else { "None" }; - property.value = choice_value(selected, &["None", "Deviation", "Limits"]); - } - } - let arrow_options: Vec = std::iter::once("Closed filled".to_string()) + let mut arrow_options: Vec = std::iter::once("Closed filled".to_string()) .chain( document .block_records @@ -1791,7 +1549,7 @@ pub fn style_sections( .filter(|name| !name.is_empty()), ) .collect(); - let linetype_options: Vec = document + let mut linetype_options: Vec = document .line_types .iter() .map(|line_type| line_type.name.clone()) @@ -1800,65 +1558,790 @@ pub fn style_sections( let text_style_options: Vec = document .text_styles .iter() - .map(|style| style.name.clone()) + .map(|record| record.name.clone()) .filter(|name| !name.is_empty()) .collect(); - for property in sections - .iter_mut() - .flat_map(|section| section.props.iter_mut()) - { - let mut current = match &property.value { - crate::scene::model::object::PropValue::ReadOnly(value) => value.clone(), - _ => continue, - }; - if matches!(property.field, "dim_arrowhead_1" | "dim_arrowhead_2") { - let code = if property.field == "dim_arrowhead_1" { - crate::entities::dim_override::DIMBLK1 - } else { - crate::entities::dim_override::DIMBLK2 - }; - if let Some(handle) = crate::entities::dim_override::handle(data, code) { - current = block_name(document, handle, ""); - } - property.value = crate::scene::model::object::PropValue::Choice { - selected: current, - options: arrow_options.clone(), - }; - } else if matches!( - property.field, - "dim_linetype" | "dim_ext_linetype_1" | "dim_ext_linetype_2" - ) { - let code = match property.field { - "dim_linetype" => crate::entities::dim_override::DIMLTYPE, - "dim_ext_linetype_1" => crate::entities::dim_override::DIMLTEX1, - _ => crate::entities::dim_override::DIMLTEX2, - }; - if let Some(handle) = crate::entities::dim_override::handle(data, code) { - current = linetype_name(document, handle); - } - property.value = crate::scene::model::object::PropValue::Choice { - selected: current, - options: linetype_options.clone(), - }; - } else if property.field == "dim_text_style" { - if let Some(handle) = crate::entities::dim_override::handle( - data, - crate::entities::dim_override::DIMTXSTY, - ) { - current = document - .text_styles - .iter() - .find(|style| style.handle == handle) - .map(|style| style.name.clone()) - .unwrap_or(current); - } - property.value = crate::scene::model::object::PropValue::Choice { - selected: current, - options: text_style_options.clone(), - }; + + let arrow_name = |code, inherited_handle, inherited_name: &str| { + ov::handle(data, code) + .map(|handle| block_name(document, handle, inherited_name)) + .unwrap_or_else(|| block_name(document, inherited_handle, inherited_name)) + }; + let linetype = |code, inherited| { + linetype_name(document, ov::handle(data, code).unwrap_or(inherited)) + }; + let arrow_1 = arrow_name(ov::DIMBLK1, s.dimblk1, &s.dimblk1_name); + let arrow_2 = arrow_name(ov::DIMBLK2, s.dimblk2, &s.dimblk2_name); + for current in [&arrow_1, &arrow_2] { + if !arrow_options.contains(current) { + arrow_options.push(current.clone()); } } - sections + for current in [ + linetype(ov::DIMLTYPE, s.dimltex_handle), + linetype(ov::DIMLTEX1, s.dimltex1_handle), + linetype(ov::DIMLTEX2, s.dimltex2_handle), + ] { + if !linetype_options.contains(¤t) { + linetype_options.push(current); + } + } + + let precision_options: Vec = (0..=8).map(|value| value.to_string()).collect(); + let linear_units = [ + "Scientific", + "Decimal", + "Engineering", + "Architectural", + "Fractional", + "Desktop", + ]; + let alternate_unit_options = [ + "Scientific", + "Decimal", + "Engineering", + "Architectural stacked", + "Fractional stacked", + "Architectural", + "Fractional", + "Desktop", + ]; + + let fill_mode = int(ov::DIMTFILL, s.dimtfill); + let fill_color = ov::color(data, ov::DIMTFILLCLR) + .unwrap_or_else(|| AcadColor::from_index(s.dimtfillclr)); + let fill_value = match fill_mode { + 1 => choice_value("Background", &["None", "Background", "Color"]), + 2 => PropValue::ColorChoice(fill_color), + _ => choice_value("None", &["None", "Background", "Color"]), + }; + + vec![ + PropSection { + title: t!("Lines & Arrows").into_owned(), + props: vec![ + property( + t!("Arrow 1").as_ref(), + "dim_arrowhead_1", + PropValue::Choice { + selected: arrow_1, + options: arrow_options.clone(), + }, + ), + property( + t!("Arrow 2").as_ref(), + "dim_arrowhead_2", + PropValue::Choice { + selected: arrow_2, + options: arrow_options, + }, + ), + number( + t!("Arrow size").as_ref(), + "dim_arrow_size", + real(ov::DIMASZ, s.dimasz), + true, + ), + property( + t!("Dim line lineweight").as_ref(), + "dim_line_lineweight", + PropValue::Choice { + selected: lineweight_label(int(ov::DIMLWD, s.dimlwd)), + options: lineweight_options(), + }, + ), + property( + t!("Ext line lineweight").as_ref(), + "dim_ext_line_lineweight", + PropValue::Choice { + selected: lineweight_label(int(ov::DIMLWE, s.dimlwe)), + options: lineweight_options(), + }, + ), + choice( + t!("Dim line 1").as_ref(), + "dim_line_1", + on(int(ov::DIMSD1, s.dimsd1 as i16) == 0), + &["On", "Off"], + true, + ), + choice( + t!("Dim line 2").as_ref(), + "dim_line_2", + on(int(ov::DIMSD2, s.dimsd2 as i16) == 0), + &["On", "Off"], + true, + ), + property( + t!("Dim line color").as_ref(), + "dim_line_color", + PropValue::ColorChoice( + ov::color(data, ov::DIMCLRD) + .unwrap_or_else(|| AcadColor::from_index(s.dimclrd)), + ), + ), + property( + t!("Dim line linetype").as_ref(), + "dim_linetype", + PropValue::Choice { + selected: linetype(ov::DIMLTYPE, s.dimltex_handle), + options: linetype_options.clone(), + }, + ), + number( + t!("Dim line ext").as_ref(), + "dim_line_ext", + real(ov::DIMDLE, s.dimdle), + true, + ), + property( + t!("Ext line 1 linetype").as_ref(), + "dim_ext_linetype_1", + PropValue::Choice { + selected: linetype(ov::DIMLTEX1, s.dimltex1_handle), + options: linetype_options.clone(), + }, + ), + property( + t!("Ext line 2 linetype").as_ref(), + "dim_ext_linetype_2", + PropValue::Choice { + selected: linetype(ov::DIMLTEX2, s.dimltex2_handle), + options: linetype_options, + }, + ), + choice( + t!("Ext line 1").as_ref(), + "dim_ext_line_1", + on(int(ov::DIMSE1, s.dimse1 as i16) == 0), + &["On", "Off"], + true, + ), + choice( + t!("Ext line 2").as_ref(), + "dim_ext_line_2", + on(int(ov::DIMSE2, s.dimse2 as i16) == 0), + &["On", "Off"], + true, + ), + choice( + t!("Ext line fixed").as_ref(), + "dim_ext_line_fixed", + on(dimfxlon), + &["On", "Off"], + true, + ), + number( + t!("Ext line fixed length").as_ref(), + "dim_ext_line_fixed_length", + real(ov::DIMFXL, s.dimfxl), + dimfxlon, + ), + property( + t!("Ext line color").as_ref(), + "dim_ext_line_color", + PropValue::ColorChoice( + ov::color(data, ov::DIMCLRE) + .unwrap_or_else(|| AcadColor::from_index(s.dimclre)), + ), + ), + number( + t!("Ext line ext").as_ref(), + "dim_ext_line_ext", + real(ov::DIMEXE, s.dimexe), + true, + ), + number( + t!("Ext line offset").as_ref(), + "dim_ext_line_offset", + real(ov::DIMEXO, s.dimexo), + true, + ), + ], + }, + PropSection { + title: t!("Text").into_owned(), + props: vec![ + property( + t!("Fill color").as_ref(), + "dim_text_fill_color", + fill_value, + ), + choice( + t!("Fractional type").as_ref(), + "dim_fractional_type", + fraction_type_label(dimfrac), + &["Horizontal", "Diagonal", "Not stacked"], + matches!(dimlunit, 4 | 5), + ), + property( + t!("Text color").as_ref(), + "dim_text_color", + PropValue::ColorChoice( + ov::color(data, ov::DIMCLRT) + .unwrap_or_else(|| AcadColor::from_index(s.dimclrt)), + ), + ), + number( + t!("Text height").as_ref(), + "dim_text_height", + real(ov::DIMTXT, s.dimtxt), + text_height_editable, + ), + number( + t!("Text offset").as_ref(), + "dim_text_offset", + dimgap.abs(), + true, + ), + choice( + t!("Text outside align").as_ref(), + "dim_text_outside_align", + on(int(ov::DIMTOH, s.dimtoh as i16) != 0), + &["On", "Off"], + true, + ), + choice( + t!("Text pos hor").as_ref(), + "dim_text_pos_hor", + text_horizontal_label(int(ov::DIMJUST, s.dimjust)), + &[ + "Centered", + "At extension line 1", + "At extension line 2", + "Over extension line 1", + "Over extension line 2", + ], + true, + ), + choice( + t!("Text pos vert").as_ref(), + "dim_text_pos_vert", + text_vertical_label(int(ov::DIMTAD, s.dimtad)), + &["Centered", "Above", "Outside", "JIS", "Below"], + true, + ), + property( + t!("Text style").as_ref(), + "dim_text_style", + PropValue::Choice { + selected: text_style_name, + options: text_style_options, + }, + ), + choice( + t!("Text inside align").as_ref(), + "dim_text_inside_align", + on(int(ov::DIMTIH, s.dimtih as i16) != 0), + &["On", "Off"], + true, + ), + property( + t!("Text position X").as_ref(), + "text_x", + PropValue::EditText(format!( + "{:.4}", + dimension.base().text_middle_point.x + )), + ), + property( + t!("Text position Y").as_ref(), + "text_y", + PropValue::EditText(format!( + "{:.4}", + dimension.base().text_middle_point.y + )), + ), + property( + t!("Text rotation").as_ref(), + "text_rotation", + PropValue::EditText(format!( + "{:.4}", + dimension.base().text_rotation.to_degrees() + )), + ), + choice( + t!("Text view direction").as_ref(), + "dim_text_view_direction", + if int(ov::DIMTXTDIRECTION, s.dimtxtdirection as i16) != 0 { + "Right-to-left" + } else { + "Left-to-right" + }, + &["Left-to-right", "Right-to-left"], + true, + ), + property( + t!("Measurement").as_ref(), + "measurement", + PropValue::ReadOnly(format!("{:.4}", dimension.measurement())), + ), + property( + t!("Text override").as_ref(), + "text_override", + PropValue::PlainText( + dimension.base().text_override().unwrap_or("").to_string(), + ), + ), + ], + }, + PropSection { + title: t!("Fit").into_owned(), + props: vec![ + choice( + t!("Fit").as_ref(), + "dim_fit", + fit_label(int(ov::DIMATFIT, s.dimatfit)), + &["Both text and arrows", "Arrows", "Text", "Best fit"], + true, + ), + choice( + t!("Text inside").as_ref(), + "dim_text_inside", + on(int(ov::DIMTIX, s.dimtix as i16) != 0), + &["On", "Off"], + true, + ), + choice( + t!("Text movement").as_ref(), + "dim_text_movement", + text_movement_label(int(ov::DIMTMOVE, s.dimtmove)), + &[ + "Keep dim line with text", + "Move text, add leader", + "Move text, no leader", + ], + true, + ), + number( + t!("Dim scale overall").as_ref(), + "dim_scale_overall", + real(ov::DIMSCALE, s.dimscale), + !annotative, + ), + choice( + t!("Dim line forced").as_ref(), + "dim_line_forced", + on(int(ov::DIMTOFL, s.dimtofl as i16) != 0), + &["On", "Off"], + true, + ), + choice( + t!("Dim line inside").as_ref(), + "dim_line_inside", + on(int(ov::DIMSOXD, s.dimsoxd as i16) != 0), + &["On", "Off"], + true, + ), + ], + }, + PropSection { + title: t!("Primary Units").into_owned(), + props: vec![ + text( + t!("Decimal separator").as_ref(), + "dim_decimal_separator", + decimal_separator, + true, + ), + text( + t!("Dim prefix").as_ref(), + "dim_prefix", + dim_prefix, + true, + ), + text( + t!("Dim suffix").as_ref(), + "dim_suffix", + dim_suffix, + true, + ), + text( + t!("Dim sub-units suffix").as_ref(), + "dim_sub_units_suffix", + string(ov::DIMMZS, &s.dimmzs), + dimzin & 4 != 0, + ), + number( + t!("Dim roundoff").as_ref(), + "dim_roundoff", + real(ov::DIMRND, s.dimrnd), + true, + ), + number( + t!("Dim scale linear").as_ref(), + "dim_scale_linear", + real(ov::DIMLFAC, s.dimlfac), + true, + ), + number( + t!("Dim sub-units scale").as_ref(), + "dim_sub_units_scale", + real(ov::DIMMZF, s.dimmzf), + dimzin & 4 != 0, + ), + choice( + t!("Dim units").as_ref(), + "dim_units", + linear_unit_label(dimlunit), + &linear_units, + true, + ), + choice( + t!("Suppress leading zeros").as_ref(), + "dim_suppress_leading_zeros", + yes(dimzin & 4 != 0), + &["Yes", "No"], + true, + ), + choice( + t!("Suppress trailing zeros").as_ref(), + "dim_suppress_trailing_zeros", + yes(dimzin & 8 != 0), + &["Yes", "No"], + true, + ), + choice( + t!("Suppress zero feet").as_ref(), + "dim_suppress_zero_feet", + yes(suppresses_zero_feet(dimzin)), + &["Yes", "No"], + true, + ), + choice( + t!("Suppress zero inches").as_ref(), + "dim_suppress_zero_inches", + yes(suppresses_zero_inches(dimzin)), + &["Yes", "No"], + true, + ), + property( + t!("Precision").as_ref(), + "dim_precision", + PropValue::Choice { + selected: int(ov::DIMDEC, s.dimdec).to_string(), + options: precision_options.clone(), + }, + ), + ], + }, + PropSection { + title: t!("Alternate Units").into_owned(), + props: vec![ + choice( + t!("Alt enabled").as_ref(), + "dim_alt_enabled", + on(dimalt), + &["On", "Off"], + true, + ), + choice( + t!("Alt format").as_ref(), + "dim_alt_format", + alternate_unit_label(int(ov::DIMALTU, s.dimaltu)), + &alternate_unit_options, + dimalt, + ), + property( + t!("Alt precision").as_ref(), + "dim_alt_precision", + if dimalt { + PropValue::Choice { + selected: int(ov::DIMALTD, s.dimaltd).to_string(), + options: precision_options.clone(), + } + } else { + PropValue::ReadOnly(int(ov::DIMALTD, s.dimaltd).to_string()) + }, + ), + number( + t!("Alt scale factor").as_ref(), + "dim_alt_scale_factor", + real(ov::DIMALTF, s.dimaltf), + dimalt, + ), + number( + t!("Alt sub-units scale").as_ref(), + "dim_alt_sub_units_scale", + real(ov::DIMALTMZF, s.dimaltmzf), + dimalt && dimaltz & 4 != 0, + ), + number( + t!("Alt roundoff").as_ref(), + "dim_alt_roundoff", + real(ov::DIMALTRND, s.dimaltrnd), + dimalt, + ), + text( + t!("Alt prefix").as_ref(), + "dim_alt_prefix", + alt_prefix, + dimalt, + ), + text( + t!("Alt suffix").as_ref(), + "dim_alt_suffix", + alt_suffix, + dimalt, + ), + text( + t!("Alt sub-units suffix").as_ref(), + "dim_alt_sub_units_suffix", + string(ov::DIMALTMZS, &s.dimaltmzs), + dimalt && dimaltz & 4 != 0, + ), + choice( + t!("Alt suppress leading zeros").as_ref(), + "dim_alt_suppress_leading_zeros", + yes(dimaltz & 4 != 0), + &["Yes", "No"], + dimalt, + ), + choice( + t!("Alt suppress trailing zeros").as_ref(), + "dim_alt_suppress_trailing_zeros", + yes(dimaltz & 8 != 0), + &["Yes", "No"], + dimalt, + ), + choice( + t!("Alt suppress zero feet").as_ref(), + "dim_alt_suppress_zero_feet", + yes(suppresses_zero_feet(dimaltz)), + &["Yes", "No"], + dimalt, + ), + choice( + t!("Alt suppress zero inches").as_ref(), + "dim_alt_suppress_zero_inches", + yes(suppresses_zero_inches(dimaltz)), + &["Yes", "No"], + dimalt, + ), + ], + }, + PropSection { + title: t!("Tolerances").into_owned(), + props: vec![ + choice( + t!("Tolerance display").as_ref(), + "dim_tolerance_display", + tolerance_display, + &["None", "Symmetrical", "Deviation", "Limits", "Basic"], + true, + ), + property( + t!("Tolerance precision").as_ref(), + "dim_tolerance_precision", + if tolerance_enabled { + PropValue::Choice { + selected: int(ov::DIMTDEC, s.dimtdec).to_string(), + options: precision_options.clone(), + } + } else { + PropValue::ReadOnly(int(ov::DIMTDEC, s.dimtdec).to_string()) + }, + ), + number( + t!("Tolerance limit lower").as_ref(), + "dim_tolerance_limit_lower", + dimtm, + matches!(tolerance_display, "Deviation" | "Limits"), + ), + number( + t!("Tolerance limit upper").as_ref(), + "dim_tolerance_limit_upper", + dimtp, + tolerance_enabled, + ), + number( + t!("Tolerance text height").as_ref(), + "dim_tolerance_text_height", + real(ov::DIMTFAC, s.dimtfac), + tolerance_enabled, + ), + choice( + t!("Tolerance pos vert").as_ref(), + "dim_tolerance_pos_vert", + tolerance_vertical_label(int(ov::DIMTOLJ, s.dimtolj)), + &["Bottom", "Middle", "Top"], + tolerance_enabled, + ), + choice( + t!("Tolerance alignment").as_ref(), + "dim_tolerance_alignment", + tolerance_alignment_label(int(ov::DIMTALN, 0)), + &["Align decimal separators", "Align operational symbols"], + tolerance_display == "Deviation", + ), + choice( + t!("Tolerance suppress leading zeros").as_ref(), + "dim_tolerance_suppress_leading_zeros", + yes(dimtzin & 4 != 0), + &["Yes", "No"], + tolerance_enabled, + ), + choice( + t!("Tolerance suppress trailing zeros").as_ref(), + "dim_tolerance_suppress_trailing_zeros", + yes(dimtzin & 8 != 0), + &["Yes", "No"], + tolerance_enabled, + ), + choice( + t!("Tolerance suppress zero feet").as_ref(), + "dim_tolerance_suppress_zero_feet", + yes(suppresses_zero_feet(dimtzin)), + &["Yes", "No"], + tolerance_enabled, + ), + choice( + t!("Tolerance suppress zero inches").as_ref(), + "dim_tolerance_suppress_zero_inches", + yes(suppresses_zero_inches(dimtzin)), + &["Yes", "No"], + tolerance_enabled, + ), + property( + t!("Alt tolerance precision").as_ref(), + "dim_alt_tolerance_precision", + if alternate_tolerance_enabled { + PropValue::Choice { + selected: int(ov::DIMALTTD, s.dimalttd).to_string(), + options: precision_options, + } + } else { + PropValue::ReadOnly(int(ov::DIMALTTD, s.dimalttd).to_string()) + }, + ), + choice( + t!("Alt tolerance suppress leading zeros").as_ref(), + "dim_alt_tolerance_suppress_leading_zeros", + yes(dimalttz & 4 != 0), + &["Yes", "No"], + alternate_tolerance_enabled, + ), + choice( + t!("Alt tolerance suppress trailing zeros").as_ref(), + "dim_alt_tolerance_suppress_trailing_zeros", + yes(dimalttz & 8 != 0), + &["Yes", "No"], + alternate_tolerance_enabled, + ), + choice( + t!("Alt tolerance suppress zero feet").as_ref(), + "dim_alt_tolerance_suppress_zero_feet", + yes(suppresses_zero_feet(dimalttz)), + &["Yes", "No"], + alternate_tolerance_enabled, + ), + choice( + t!("Alt tolerance suppress zero inches").as_ref(), + "dim_alt_tolerance_suppress_zero_inches", + yes(suppresses_zero_inches(dimalttz)), + &["Yes", "No"], + alternate_tolerance_enabled, + ), + ], + }, + ] +} + +fn property(label: &str, field: &'static str, value: PropValue) -> Property { + Property { + label: label.to_string(), + field, + value, + } +} + +fn split_measurement_template(value: &str) -> (String, String) { + value + .split_once("<>") + .map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string())) + .unwrap_or_else(|| (String::new(), value.to_string())) +} + +fn fraction_type_label(value: i16) -> &'static str { + match value { + 1 => "Diagonal", + 2 => "Not stacked", + _ => "Horizontal", + } +} + +fn suppresses_zero_feet(value: i16) -> bool { + matches!(value & 3, 0 | 3) +} + +fn suppresses_zero_inches(value: i16) -> bool { + matches!(value & 3, 0 | 2) +} + +fn text_horizontal_label(value: i16) -> &'static str { + match value { + 1 => "At extension line 1", + 2 => "At extension line 2", + 3 => "Over extension line 1", + 4 => "Over extension line 2", + _ => "Centered", + } +} + +fn text_vertical_label(value: i16) -> &'static str { + match value { + 1 => "Above", + 2 => "Outside", + 3 => "JIS", + 4 => "Below", + _ => "Centered", + } +} + +fn fit_label(value: i16) -> &'static str { + match value { + 1 => "Arrows", + 2 => "Text", + 3 => "Best fit", + _ => "Both text and arrows", + } +} + +fn text_movement_label(value: i16) -> &'static str { + match value { + 1 => "Move text, add leader", + 2 => "Move text, no leader", + _ => "Keep dim line with text", + } +} + +fn alternate_unit_label(value: i16) -> &'static str { + match value { + 1 => "Scientific", + 3 => "Engineering", + 4 => "Architectural stacked", + 5 => "Fractional stacked", + 6 => "Architectural", + 7 => "Fractional", + 8 => "Desktop", + _ => "Decimal", + } +} + +fn tolerance_vertical_label(value: i16) -> &'static str { + match value { + 0 => "Bottom", + 2 => "Top", + _ => "Middle", + } +} + +fn tolerance_alignment_label(value: i16) -> &'static str { + if value == 0 { + "Align decimal separators" + } else { + "Align operational symbols" + } } fn choice_value( @@ -1873,11 +2356,7 @@ fn choice_value( fn block_name(document: &CadDocument, handle: acadrust::Handle, fallback: &str) -> String { if handle.is_null() { - return if fallback.is_empty() { - "Closed filled".to_string() - } else { - fallback.to_string() - }; + return "Closed filled".to_string(); } document .block_records @@ -1907,15 +2386,6 @@ fn linear_unit_label(value: i16) -> &'static str { } } -fn angle_unit_label(value: i16) -> &'static str { - match value { - 1 => "Degrees/minutes/seconds", - 2 => "Gradians", - 3 => "Radians", - 4 => "Surveyors units", - _ => "Decimal degrees", - } -} use acadrust::{CadDocument, EntityType, Handle}; use crate::scene::convert::tess_util::aci_to_rgba; @@ -2148,6 +2618,8 @@ fn tessellate_dimension_inner( real!(dimtfac, DIMTFAC); real!(dimgap, DIMGAP); real!(dimaltrnd, DIMALTRND); + real!(dimaltmzf, DIMALTMZF); + real!(dimmzf, DIMMZF); flag!(dimtol, DIMTOL); flag!(dimlim, DIMLIM); flag!(dimtih, DIMTIH); @@ -2165,6 +2637,7 @@ fn tessellate_dimension_inner( flag!(dimfxlon, DIMFXLON); flag!(dimtxtdirection, DIMTXTDIRECTION); int!(dimzin, DIMZIN); + int!(dimtad, DIMTAD); int!(dimazin, DIMAZIN); int!(dimarcsym, DIMARCSYM); int!(dimclrd, DIMCLRD); @@ -2204,6 +2677,12 @@ fn tessellate_dimension_inner( if let Some(value) = ov::string(data, ov::DIMAPOST) { style.dimapost = value; } + if let Some(value) = ov::string(data, ov::DIMALTMZS) { + style.dimaltmzs = value; + } + if let Some(value) = ov::string(data, ov::DIMMZS) { + style.dimmzs = value; + } if let Some(value) = ov::handle(data, ov::DIMTXSTY) { style.dimtxsty_handle = value; if let Some(record) = document.text_styles.iter().find(|record| record.handle == value) { @@ -2324,12 +2803,15 @@ fn tessellate_dimension_inner( // Text box (local space) so the dim line can be broken where the text // crosses it — lets a DIMTFILL background sit over the line. The renderer // draws 2D fills under all wires, so the line is gapped rather than masked. - let dimgap_local = style.map(|s| (s.dimgap * dim_scale) as f32).unwrap_or(0.09); + let dimgap_local = style + .map(|s| (s.dimgap.abs() * dim_scale) as f32) + .unwrap_or(0.09); + let text_width = dimension_text_value(dim, style) + .map(|text| text.chars().count() as f32 * dim_txt as f32 * 0.6 + dimgap_local * 2.0) + .unwrap_or(0.0); let text_break = { let tp = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); - let tw = dimension_text_value(dim, style) - .map(|t| t.chars().count() as f32 * dim_txt as f32 * 0.6) - .unwrap_or(0.0); + let tw = (text_width - dimgap_local * 2.0).max(0.0); if tw > 0.0 { // Vertical threshold is the bare text half-height (no DIMGAP): the // line only breaks when it actually passes under the glyphs. Text @@ -2359,6 +2841,9 @@ fn tessellate_dimension_inner( dimcen, ticks: dimtsz_raw > 1e-9, arrow_len: dimasz, + text_width, + dimatfit: style.map(|s| s.dimatfit).unwrap_or(3), + dimtofl: style.map(|s| s.dimtofl).unwrap_or(false), text_break, }, SuppressFlags { @@ -2381,18 +2866,9 @@ fn tessellate_dimension_inner( } } } - // Fit: text that doesn't fit between the extension lines is slid outside - // in the text-placement pass (unless DIMTIX), and arrowheads that don't - // fit are flipped outside in append_linear_dimension. DIMTOFL (force the - // dim line inside) is already satisfied for linear/aligned, whose dim - // line is always drawn between the extension points. DIMATFIT's exact - // mode ordering (arrows-first vs text-first) and DIMUPT (reposition on - // create) aren't differentiated yet — read here for round-trip. - let _ = (s.dimtofl, s.dimatfit, s.dimupt); - // DIMTXTDIRECTION (RTL) needs per-instance text mirroring on the Text - // entity, which the current text struct can't carry. Tracked: read - // and ignore so the file round-trips on save. - let _ = s.dimtxtdirection; + // DIMUPT governs interactive creation-time text placement; saved + // geometry already carries the resulting position. + let _ = s.dimupt; // DIMARCSYM only applies to arc-length dims; DIMJOGANG only to // jogged-radius dims. We don't ship those Dimension variants yet, // so the values are read for round-trip but not drawn. @@ -2672,6 +3148,49 @@ fn tessellate_dimension_inner( } } + // A negative DIMGAP denotes the Basic tolerance display: frame the + // dimension text while keeping the absolute gap as the frame margin. + if style.is_some_and(|s| s.dimgap < 0.0) { + if let Some(rect) = text_fill_rect(dim, style, dim_txt, dim_scale) { + let p1 = rect[0]; + let p2 = rect[1]; + let p3 = rect[2]; + let p4 = rect[5]; + wires.push(WireModel { + point_marker: None, + taper_widths: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.clone(), + points: vec![p1, p2, p2, p3, p3, p4, p4, p1], + points_low: Vec::new(), + color: if selected { WireModel::SELECTED } else { text_color }, + selected, + aci: 0, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: vec![], + tangent_geoms: vec![], + key_vertices: vec![], + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } + } + if let Some(synth_text_entity) = dimension_text_entity(dim, dim_txt, style, document, dim_scale) { // Tolerance Text rendered separately so DIMTFAC scales its height @@ -2837,7 +3356,7 @@ fn text_fill_rect( return None; } let pos = dimension_text_pos_f64(dim, style, text_height, dim_scale); - let dimgap = style.map(|s| s.dimgap).unwrap_or(0.0).max(0.0) * dim_scale; + let dimgap = style.map(|s| s.dimgap.abs()).unwrap_or(0.0) * dim_scale; // ~0.6 × text_height per character; matches average glyph aspect for // the bundled stick fonts. Inflate by 1 DIMGAP on each side. let approx_w = value.chars().count() as f64 * text_height * 0.6 + dimgap * 2.0; @@ -2883,6 +3402,9 @@ struct DimLineParams { ticks: bool, /// Arrowhead length (DIMASZ, scaled) — used to decide arrow-outside fit. arrow_len: f32, + text_width: f32, + dimatfit: i16, + dimtofl: bool, /// Text box (local centre, half-width, half-height) used to break the /// dimension line where the text sits on it, so a DIMTFILL background reads /// over the line. None when the text doesn't overlap the line. @@ -3190,12 +3712,22 @@ fn append_linear_dimension( let d1_out = d1 - dir_d1_to_d2 * dle; let d2_out = d2 + dir_d1_to_d2 * dle; - // DIMATFIT fit: when the arrowheads don't fit between the extension lines - // they're flipped to the outside (point still on the ext line, body - // outside) with a short stub for them to sit on. DIMSOXD suppresses those - // outer stubs. Ticks always fit, so this only affects arrowheads. + // When text plus arrows do not fit, DIMATFIT decides which component moves + // first: 0=both, 1=arrows, 2=text, 3=best fit. let gap = (d2 - d1).length(); - let arrows_outside = !params.ticks && params.arrow_len > 1e-6 && gap < 2.0 * params.arrow_len; + let arrows_outside = if params.ticks || params.arrow_len <= 1e-6 { + false + } else if gap < 2.0 * params.arrow_len { + true + } else if gap < params.text_width + 2.0 * params.arrow_len { + match params.dimatfit { + 0 | 1 => true, + 2 => false, + _ => params.text_width <= gap, + } + } else { + false + }; // The two suppression flags address the portions at the first and second // measured points independently. The text gap is also the natural split; @@ -3221,14 +3753,15 @@ fn append_linear_dimension( } } } - if !suppress.dim1 && left_end > 1e-6 { + let draw_inside_line = !arrows_outside || params.dimtofl; + if draw_inside_line && !suppress.dim1 && left_end > 1e-6 { add_segment( &mut g.dim_lines, d1_out, d1_out + line_dir * left_end, ); } - if !suppress.dim2 && line_len - right_start > 1e-6 { + if draw_inside_line && !suppress.dim2 && line_len - right_start > 1e-6 { add_segment( &mut g.dim_lines, d1_out + line_dir * right_start, @@ -3539,7 +4072,9 @@ fn dimension_text_entity( document: &CadDocument, dim_scale: f64, ) -> Option { - let value = dimension_text_value(dim, style)?; + // Tolerances are emitted by `dimension_tolerance_entity` at their own + // height and alignment; keep the primary entity free of duplicate text. + let (value, _) = dimension_text_parts(dim, style)?; // Use f64 position directly to avoid f32 round-trip precision loss at large // coordinates (e.g. Turkish UTM ~4,000,000 m). tessellate() will apply // world_offset when rendering this synthetic entity. @@ -3601,6 +4136,11 @@ fn dimension_text_entity( return Some(EntityType::MText(mtext)); } + let value = if style.is_some_and(|style| style.dimtxtdirection) { + value.chars().rev().collect() + } else { + value + }; let mut text = Text::with_value(value, pos_f64) .with_height(text_height) .with_rotation(rotation); @@ -3654,11 +4194,12 @@ fn dimension_text_rotation(dim: &Dimension, style: Option<&DimStyle>) -> f64 { let dimtih = style.map(|s| s.dimtih).unwrap_or(false); let dimtoh = style.map(|s| s.dimtoh).unwrap_or(false); let dimjust = style.map(|s| s.dimjust).unwrap_or(0); + let outside = dimension_text_is_outside(dim, style); if base.text_rotation.abs() > 1e-9 { base.text_rotation } else if base.horizontal_direction.abs() > 1e-9 { base.horizontal_direction - } else if dimtih || dimtoh { + } else if (outside && dimtoh) || (!outside && dimtih) { 0.0 } else { let mut r = dimension_text_natural_rotation(dim); @@ -3669,6 +4210,52 @@ fn dimension_text_rotation(dim: &Dimension, style: Option<&DimStyle>) -> f64 { } } +fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool { + let Some(style) = style else { + return false; + }; + let (first, second, axis) = match dim { + Dimension::Linear(d) => ( + d.first_point, + d.second_point, + Vector3::new(d.rotation.cos(), d.rotation.sin(), 0.0), + ), + Dimension::Aligned(d) => { + let delta = d.second_point - d.first_point; + let length = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1e-12); + (d.first_point, d.second_point, delta / length) + } + _ => return false, + }; + let first_axis = first.x * axis.x + first.y * axis.y; + let second_axis = second.x * axis.x + second.y * axis.y; + let lo = first_axis.min(second_axis); + let hi = first_axis.max(second_axis); + if dim.base().text_user_positioned { + let point = dim.base().text_middle_point; + let position = point.x * axis.x + point.y * axis.y; + return position < lo || position > hi; + } + if style.dimtix { + return false; + } + let scale = if style.dimscale > 1e-9 { style.dimscale } else { 1.0 }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let span = hi - lo; + let insufficient = text_width + arrow * 2.0 > span; + insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > span, + _ => text_width > span, + } +} + fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let angle = match dim { Dimension::Linear(d) => d.rotation, @@ -3726,7 +4313,7 @@ fn dimension_text_parts( // Build tolerance / limits suffix separately so the caller can render // it as its own Text entity at DIMTFAC × DIMTXT height. - let tolerance_suffix = build_tolerance_suffix(dim.measurement(), style, is_angular); + let tolerance_suffix = build_tolerance_suffix(dim, style, is_angular); let primary = apply_dimpost(&primary_raw, style); // Alternate units appended in brackets when DIMALT is on (linear only). @@ -3754,21 +4341,22 @@ fn dimension_text_parts( } fn build_tolerance_suffix( - measurement: f64, + dim: &Dimension, style: Option<&DimStyle>, is_angular: bool, ) -> Option { let s = style?; + let measurement = dim.measurement(); let dimtdec = s.dimtdec.max(0) as usize; let dimtzin = s.dimtzin; let fmt = |v: f64| -> String { let raw = format!("{:.*}", dimtdec, v); - apply_linear_zero_suppression(&raw, dimtzin) + swap_decimal_sep(&apply_linear_zero_suppression(&raw, dimtzin), s.dimdsep) }; if s.dimlim { let high = measurement + s.dimtp; let low = measurement - s.dimtm; - return Some(format!("{}/{}", fmt(high), fmt(low))); + return Some(format!("\\S{}^{};", fmt(high), fmt(low))); } if s.dimtol { let unit = if is_angular { "°" } else { "" }; @@ -3776,18 +4364,44 @@ fn build_tolerance_suffix( return Some(format!("±{}{}", fmt(s.dimtp), unit)); } if s.dimtp.abs() > 1e-12 || s.dimtm.abs() > 1e-12 { + let mut upper = fmt(s.dimtp); + let mut lower = fmt(s.dimtm); + let alignment = crate::entities::dim_override::int( + &dim.base().common.extended_data, + crate::entities::dim_override::DIMTALN, + ) + .unwrap_or(0); + if alignment == 0 { + align_tolerance_decimals(&mut upper, &mut lower, s.dimdsep); + } return Some(format!( - "+{}{} / -{}{}", - fmt(s.dimtp), - unit, - fmt(s.dimtm), - unit + "\\S+{}{}^-{}{};", + upper, unit, lower, unit )); } } None } +fn align_tolerance_decimals(upper: &mut String, lower: &mut String, separator: i16) { + let separator = separator as u8 as char; + let integer_width = |value: &str| { + value + .find(separator) + .or_else(|| value.find('.')) + .unwrap_or(value.len()) + }; + let upper_width = integer_width(upper); + let lower_width = integer_width(lower); + let target = upper_width.max(lower_width); + if upper_width < target { + upper.insert_str(0, &" ".repeat(target - upper_width)); + } + if lower_width < target { + lower.insert_str(0, &" ".repeat(target - lower_width)); + } +} + /// Build the bracketed alternate-units suffix when DIMALT is enabled. /// When DIMTOL is also on, the bracketed text includes the tolerance /// component formatted with DIMALTTD / DIMALTTZ. @@ -3800,10 +4414,19 @@ fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option 1e-12 { v = (v / s.dimaltrnd).round() * s.dimaltrnd; } + let use_sub_units = s.dimaltz & 4 != 0 + && v.abs() < 1.0 + && s.dimaltmzf.abs() > 1e-12; + if use_sub_units { + v *= s.dimaltmzf; + } let dec = s.dimaltd.max(0) as usize; - let raw = format_with_unit(v, s.dimaltu, dec, s.dimfrac, s.dimaltz); + let raw = format_with_unit(v, s.dimaltu, dec, s.dimfrac, s.dimaltz, true); let suppressed = apply_linear_zero_suppression(&raw, s.dimaltz); - let sep_swapped = swap_decimal_sep(&suppressed, s.dimdsep); + let mut sep_swapped = swap_decimal_sep(&suppressed, s.dimdsep); + if use_sub_units { + sep_swapped.push_str(&s.dimaltmzs); + } // Alt-unit tolerance suffix using DIMALTTD / DIMALTTZ. let alt_value = if s.dimtol { let alttdec = s.dimalttd.max(0) as usize; @@ -3857,7 +4480,7 @@ fn dimension_tolerance_entity( ) -> Option { let s = style?; let is_angular = matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)); - let tol = build_tolerance_suffix(dim.measurement(), style, is_angular)?; + let tol = build_tolerance_suffix(dim, style, is_angular)?; let dimtfac = if s.dimtfac.abs() < 1e-12 { 1.0 } else { @@ -3889,7 +4512,12 @@ fn dimension_tolerance_entity( // Approximate widths from glyph counts (~0.6 × cell size per char). let primary_w = primary_value_len as f64 * primary_height * 0.6; - let tol_w = tol.chars().count() as f64 * tol_height * 0.6; + let tol_visible_chars = tol + .strip_prefix("\\S") + .and_then(|value| value.strip_suffix(';')) + .map(|value| value.split('^').map(str::len).max().unwrap_or(0)) + .unwrap_or_else(|| tol.chars().count()); + let tol_w = tol_visible_chars as f64 * tol_height * 0.6; let gap = primary_height * 0.2; let dx_local = primary_w * 0.5 + tol_w * 0.5 + gap; let dy_local = match s.dimtolj { @@ -3904,6 +4532,16 @@ fn dimension_tolerance_entity( primary_insertion.y + dx_local * sr + dy_local * cr, primary_insertion.z, ); + if value_has_mtext_codes(&tol) { + let mut mtext = MText::with_value(tol, pos); + mtext.height = tol_height; + mtext.rotation = rot; + mtext.style = primary_style; + mtext.attachment_point = acadrust::entities::AttachmentPoint::MiddleCenter; + mtext.common = primary_common; + return Some(EntityType::MText(mtext)); + } + let mut t = Text::with_value(tol, pos) .with_height(tol_height) .with_rotation(rot); @@ -3930,23 +4568,39 @@ fn apply_dimpost(value: &str, style: Option<&DimStyle>) -> String { /// Format a linear measurement honouring DIMLFAC, DIMRND, DIMDEC, DIMZIN, DIMDSEP, DIMLUNIT. fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String { - let (dec, zin, lfac, rnd, dsep, lunit, frac) = style + let (dec, zin, lfac, rnd, dsep, lunit, frac, sub_factor, sub_suffix) = style .map(|s| { ( - s.dimdec, s.dimzin, s.dimlfac, s.dimrnd, s.dimdsep, s.dimlunit, s.dimfrac, + s.dimdec, + s.dimzin, + s.dimlfac, + s.dimrnd, + s.dimdsep, + s.dimlunit, + s.dimfrac, + s.dimmzf, + s.dimmzs.as_str(), ) }) - .unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0)); + .unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0, 1.0, "")); let lfac = if lfac.abs() < 1e-12 { 1.0 } else { lfac }; let mut v = measurement * lfac; if rnd > 1e-12 { v = (v / rnd).round() * rnd; } + let use_sub_units = zin & 4 != 0 && v.abs() < 1.0 && sub_factor.abs() > 1e-12; + if use_sub_units { + v *= sub_factor; + } let dec = dec.max(0) as usize; - let raw = format_with_unit(v, lunit, dec, frac, zin); + let raw = format_with_unit(v, lunit, dec, frac, zin, false); let suppressed = apply_linear_zero_suppression(&raw, zin); - swap_decimal_sep(&suppressed, dsep) + let mut formatted = swap_decimal_sep(&suppressed, dsep); + if use_sub_units { + formatted.push_str(sub_suffix); + } + formatted } /// Dispatch on DIMLUNIT / DIMALTU. @@ -3955,15 +4609,23 @@ fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String { /// 3 = Engineering (feet + decimal inches; 1 unit = 1 inch) /// 4 = Architectural (feet + fractional inches) /// 5 = Fractional (integer + fractional inches) -/// 6 = Windows desktop → falls back to Decimal -/// `dimfrac` controls denominator power for arch/fractional output (0/1/2); -/// rendered inline as "n/d" (stacked glyphs require MText support). -fn format_with_unit(value: f64, unit: i16, dec: usize, dimfrac: i16, zin: i16) -> String { +/// For alternate units, 4/5 are stacked and 6/7 are unstacked architectural +/// and fractional forms. For primary units DIMFRAC selects the stack form. +fn format_with_unit( + value: f64, + unit: i16, + dec: usize, + dimfrac: i16, + zin: i16, + alternate: bool, +) -> String { match unit { 1 => format!("{:.*e}", dec, value), 3 => format_engineering(value, dec), - 4 => format_architectural(value, dimfrac, zin), - 5 => format_fractional(value, dimfrac), + 4 => format_architectural(value, dec, if alternate { 0 } else { dimfrac }, zin), + 5 => format_fractional(value, dec, if alternate { 0 } else { dimfrac }), + 6 if alternate => format_architectural(value, dec, 2, zin), + 7 if alternate => format_fractional(value, dec, 2), _ => format!("{:.*}", dec, value), } } @@ -3976,8 +4638,8 @@ fn format_engineering(inches: f64, dec: usize) -> String { format!("{}{:.0}'-{:.*}\"", sign, feet, dec, rem_in) } -fn format_architectural(inches: f64, dimfrac: i16, zin: i16) -> String { - let denom = arch_denom(dimfrac); +fn format_architectural(inches: f64, precision: usize, dimfrac: i16, zin: i16) -> String { + let denom = fractional_denominator(precision); // Round to the nearest 1/denom inch *first*, in integer ticks, so a fraction // that rounds up to a whole inch carries into the inches — and on into the // feet — instead of printing an un-reduced "11 1/1". A 3ft object that @@ -3988,7 +4650,7 @@ fn format_architectural(inches: f64, dimfrac: i16, zin: i16) -> String { let feet = ticks / per_foot; let rem = ticks % per_foot; let whole = rem / denom; - let frac_str = reduce_fraction(rem % denom, denom); + let frac_str = format_fraction_component(&reduce_fraction(rem % denom, denom), dimfrac); // DIMZIN feet/inch suppression: // 0 suppress zero feet & zero inches, 1 include both, @@ -4024,13 +4686,13 @@ fn format_architectural(inches: f64, dimfrac: i16, zin: i16) -> String { format!("{}{}", sign, body) } -fn format_fractional(value: f64, dimfrac: i16) -> String { - let denom = arch_denom(dimfrac); +fn format_fractional(value: f64, precision: usize, dimfrac: i16) -> String { + let denom = fractional_denominator(precision); // Round on the fraction grid first (same carry reasoning as architectural). let ticks = (value.abs() * denom as f64).round() as u64; let sign = if value < 0.0 && ticks != 0 { "-" } else { "" }; let whole = ticks / denom; - let frac_str = reduce_fraction(ticks % denom, denom); + let frac_str = format_fraction_component(&reduce_fraction(ticks % denom, denom), dimfrac); if frac_str.is_empty() { format!("{}{}", sign, whole) } else if whole == 0 { @@ -4040,12 +4702,18 @@ fn format_fractional(value: f64, dimfrac: i16) -> String { } } -/// Power-of-two denominator for architectural / fractional output. DIMFRAC is -/// the exponent-like fraction-format knob; clamp it to a readable -/// range (16ths … 1024ths). -fn arch_denom(dimfrac: i16) -> u64 { - let exp = (dimfrac.clamp(0, 8) as u32).max(2) + 2; // 4..=10 → 16..=1024 - 1u64 << exp +/// Fraction precision maps 0..8 to whole units through 1/256. DIMFRAC controls +/// only the visual stack form; it must not change the numeric denominator. +fn fractional_denominator(precision: usize) -> u64 { + 1u64 << precision.min(8) +} + +fn format_fraction_component(value: &str, dimfrac: i16) -> String { + if value.is_empty() || dimfrac == 2 { + return value.to_string(); + } + let separator = if dimfrac == 1 { '#' } else { '/' }; + format!("\\S{};", value.replacen('/', &separator.to_string(), 1)) } /// Reduce a power-of-two fraction numer/denom to display form. Empty for a zero @@ -4217,6 +4885,7 @@ fn text_on_dim_line( text_w: f64, arrow: f64, dimtix: bool, + dimatfit: i16, tad: i16, ) -> Vector3 { // The perpendicular "up" side: the perpendicular of the dimension line @@ -4256,13 +4925,20 @@ fn text_on_dim_line( 2 | 4 => t2, _ => (t1 + t2) * 0.5, }; - // DIMATFIT / DIMTIX fit: when centred text can't fit between the extension - // lines, slide it just past the far one (text-outside placement) unless - // DIMTIX forces it to stay inside. + // DIMATFIT / DIMTIX fit: move text according to the selected priority when + // the combined text-and-arrow envelope cannot fit. if dimjust == 0 && !dimtix && text_w > 0.0 { let lo = t1.min(t2); let hi = t1.max(t2); - if text_w > (hi - lo) - 2.0 * arrow { + let span = hi - lo; + let insufficient = text_w + 2.0 * arrow > span; + let text_outside = insufficient + && match dimatfit { + 0 | 2 => true, + 1 | 3 => text_w > span, + _ => text_w > span, + }; + if text_outside { along = hi + arrow + text_w * 0.5; } } @@ -4289,10 +4965,11 @@ fn dimension_text_pos_f64( let dimtad = style.map(|s| s.dimtad).unwrap_or(1); // DIMGAP scales with DIMSCALE just like DIMTXT, so the text-to-line gap // stays consistent when DIMSCALE != 1. - let dimgap = style.map(|s| s.dimgap).unwrap_or(0.0) * dim_scale; + let dimgap = style.map(|s| s.dimgap.abs()).unwrap_or(0.0) * dim_scale; let dimjust = style.map(|s| s.dimjust).unwrap_or(0); // DIMTIX forces the text to stay between the extension lines. let dimtix = style.map(|s| s.dimtix).unwrap_or(false); + let dimatfit = style.map(|s| s.dimatfit).unwrap_or(3); // DIMTVP vertical-position multiplier (units of dimtxt). Only honoured when // DIMTAD == 0; offsets text perpendicular to the dim line. let dimtvp = style.map(|s| s.dimtvp).unwrap_or(0.0); @@ -4332,6 +5009,7 @@ fn dimension_text_pos_f64( text_w, arrow, dimtix, + dimatfit, dimtad, ) } @@ -4350,6 +5028,7 @@ fn dimension_text_pos_f64( text_w, arrow, dimtix, + dimatfit, dimtad, ) } @@ -4465,11 +5144,11 @@ mod dimtad_tests { fn above_is_up_regardless_of_direction() { let (first, second, defpt) = (v(0.0, 10.0), v(20.0, 10.0), v(0.0, 0.0)); let perp = 5.0; - let fwd = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 1); - let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 1); + let fwd = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1); + let rev = text_on_dim_line(first, second, defpt, -1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 1); assert!(fwd.y > 0.0, "above must be +Y, got {}", fwd.y); assert!(rev.y > 0.0, "above must be +Y even reversed, got {}", rev.y); - let below = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 4); + let below = text_on_dim_line(first, second, defpt, 1.0, 0.0, 0, perp, 0.0, 1.0, false, 3, 4); assert!(below.y < 0.0, "below must be -Y, got {}", below.y); } @@ -4490,6 +5169,7 @@ mod dimtad_tests { 0.0, 1.0, false, + 3, 2, ); assert!( @@ -4509,6 +5189,7 @@ mod dimtad_tests { 0.0, 1.0, false, + 3, 2, ); assert!( @@ -4528,30 +5209,30 @@ mod arch_format_tests { // for the fraction that reduced to 1/1 and leaked out as a bare "1".) #[test] fn arch_carries_fraction_up_to_feet() { - assert_eq!(format_architectural(35.99, 0, 1), "3'-0\""); - assert_eq!(format_architectural(36.0, 0, 1), "3'-0\""); + assert_eq!(format_architectural(35.99, 4, 2, 1), "3'-0\""); + assert_eq!(format_architectural(36.0, 4, 2, 1), "3'-0\""); // An exact 15/16 must still render as a fraction, no spurious carry. - assert_eq!(format_architectural(35.9375, 0, 1), "2'-11 15/16\""); + assert_eq!(format_architectural(35.9375, 4, 2, 1), "2'-11 15/16\""); } // Carry that stops at inches (11.999" → 12" → 1'-0", not "0'-11 1""). #[test] fn arch_carries_fraction_up_to_inches() { - assert_eq!(format_architectural(11.999, 0, 1), "1'-0\""); + assert_eq!(format_architectural(11.999, 4, 2, 1), "1'-0\""); } #[test] fn arch_normal_values_unchanged() { - assert_eq!(format_architectural(30.5, 0, 1), "2'-6 1/2\""); - assert_eq!(format_architectural(0.0, 0, 1), "0'-0\""); - assert_eq!(format_architectural(-30.25, 0, 1), "-2'-6 1/4\""); + assert_eq!(format_architectural(30.5, 4, 2, 1), "2'-6 1/2\""); + assert_eq!(format_architectural(0.0, 4, 2, 1), "0'-0\""); + assert_eq!(format_architectural(-30.25, 4, 2, 1), "-2'-6 1/4\""); } // Same carry bug lived in the plain fractional formatter. #[test] fn fractional_carries_up() { - assert_eq!(format_fractional(35.9999, 0), "36"); - assert_eq!(format_fractional(11.999, 0), "12"); - assert_eq!(format_fractional(6.5, 0), "6 1/2"); + assert_eq!(format_fractional(35.9999, 4, 2), "36"); + assert_eq!(format_fractional(11.999, 4, 2), "12"); + assert_eq!(format_fractional(6.5, 4, 2), "6 1/2"); } } From 80d7a8741eb620314be88529b06b3ae930b2bff1 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 15:32:38 +0300 Subject: [PATCH 30/98] Route dimension color fields correctly --- src/app/mod.rs | 2 ++ src/app/update/mod.rs | 19 +++++++++++++++++++ src/app/update/style.rs | 3 +++ src/app/view/overlay.rs | 1 + src/ui/color_select.rs | 28 +++++++++++++++++++++++++--- src/ui/properties.rs | 28 +++++++++++++++++++++++++--- src/ui/ribbon/mod.rs | 1 + src/ui/style/dimstyle.rs | 1 + src/ui/style/mleaderstyle.rs | 6 +++++- src/ui/style/tablestyle.rs | 6 +++++- src/ui/window/layer_state_manager.rs | 1 + src/ui/window/layers.rs | 1 + 12 files changed, 89 insertions(+), 8 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 9e587708..3830c21d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1218,6 +1218,8 @@ pub enum ColorPickTarget { Properties, /// Selected entities' background colour (hatch / MTEXT background row). PropertiesBg, + /// A named per-entity Properties colour field. + PropertiesField(String), /// Current creation colour (ribbon). Ribbon, /// A layer's colour, by panel row index. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2dd58b15..24bb99c5 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4841,6 +4841,11 @@ impl OpenCADStudio { | "dim_text_color" | "dim_text_fill_color" ) { + let fill_mode = (field == "dim_text_fill_color").then(|| match color { + acadrust::types::Color::None => 0, + acadrust::types::Color::ByBlock => 1, + _ => 2, + }); let aci = color.approximate_index(); let code = match field.as_str() { "dim_ext_line_color" => crate::entities::dim_override::DIMCLRE, @@ -4862,6 +4867,19 @@ impl OpenCADStudio { if !targets.is_empty() { self.push_undo_snapshot(i, "CHPROP"); for &handle in &targets { + if field == "dim_text_fill_color" { + crate::entities::dim_override::set( + &mut self.tabs[i].scene.document, + handle, + crate::entities::dim_override::DIMTFILL, + Some(acadrust::xdata::XDataValue::Integer16( + fill_mode.unwrap_or(2), + )), + ); + if fill_mode != Some(2) { + continue; + } + } crate::entities::dim_override::set( &mut self.tabs[i].scene.document, handle, @@ -7539,6 +7557,7 @@ impl OpenCADStudio { let i = self.active_tab; self.tabs[i].properties.color_picker_open = false; + self.tabs[i].properties.open_color_field = None; self.tabs[i].layers.color_picker_row = None; Task::none() diff --git a/src/app/update/style.rs b/src/app/update/style.rs index 4a13eb63..3574338d 100644 --- a/src/app/update/style.rs +++ b/src/app/update/style.rs @@ -1146,6 +1146,9 @@ pub(super) fn on_text_style_dialog_open(&mut self) -> Task { Some(crate::app::ColorPickTarget::PropertiesBg) => { Some(Message::PropBgColorChanged(color)) } + Some(crate::app::ColorPickTarget::PropertiesField(field)) => { + Some(Message::PropColorFieldChanged { field, color }) + } Some(crate::app::ColorPickTarget::MText) => { Some(Message::MTextColorChanged(color)) } diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index 82e6f68b..b5782f81 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -474,6 +474,7 @@ fn mtext_editor_content<'a>( crate::ui::color_select::ColorExtras { by_layer: true, by_block: false, + ..Default::default() }, Message::MTextColorChanged, Message::MTextColorPickerToggle, diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index 233ec87e..0bc203cc 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -16,6 +16,8 @@ use crate::t; pub struct ColorExtras { pub by_layer: bool, pub by_block: bool, + pub none: bool, + pub background: bool, } /// Encode a colour as the ACI integer string the style editors store @@ -38,7 +40,7 @@ pub fn iced_to_acad_color(color: Color) -> AcadColor { AcadColor::Rgb { r, g, b } } -/// Return the closest AutoCAD Color Index for an RGB colour. +/// Return the closest CAD Color Index for an RGB colour. pub fn nearest_aci(r: u8, g: u8, b: u8) -> u8 { let mut best = 7; let mut best_distance = u32::MAX; @@ -209,7 +211,27 @@ pub fn color_list<'a>( .into() }; + let logical_row = |color: AcadColor, label: &'static str| -> Element<'a, Message> { + let (bg, _) = acad_color_display(color); + button( + row![swatch(bg), text(t!(label)).size(11)] + .spacing(5) + .align_y(iced::Center), + ) + .on_press(on_select(color)) + .style(list_row_style) + .padding([2, 4]) + .width(Length::Fill) + .into() + }; + let mut list = column![].spacing(1); + if extras.none { + list = list.push(named_row(AcadColor::None)); + } + if extras.background { + list = list.push(logical_row(AcadColor::ByBlock, "Background")); + } if extras.by_layer { list = list.push(named_row(AcadColor::ByLayer)); } @@ -297,7 +319,7 @@ pub fn index_color_page<'a>( .into() }; - // AutoCAD's first standard indexed colours. + // The first standard indexed CAD colours. let mut standard = row![].spacing(4); for idx in 1u8..=9 { standard = standard.push(swatch_button(AcadColor::Index(idx), 22.0)); @@ -402,7 +424,7 @@ pub fn index_color_page<'a>( tabs, text("Standard colors").size(11), standard, - text("AutoCAD Color Index (ACI) 10–249").size(11), + text("CAD Color Index (ACI) 10–249").size(11), text("Color family → · Shade / intensity ↓") .size(9) diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 1a036f4c..5d562dd4 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -834,6 +834,7 @@ impl PropertiesPanel { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() }, Message::PropBgColorChanged, Message::PropBgColorPickerToggle, @@ -851,18 +852,35 @@ impl PropertiesPanel { // entity's main colour. Used by hatch gradient colours and the dim-line // colour override (Leader / Dimension). Dim colours legitimately take // ByLayer / ByBlock; gradient colours do not. - if field == "gradient_color_1" || field == "gradient_color_2" || field == "dim_line_color" { + if matches!( + field, + "gradient_color_1" + | "gradient_color_2" + | "dim_line_color" + | "dim_ext_line_color" + | "dim_text_color" + | "dim_text_fill_color" + ) { let open = self.open_color_field.as_deref() == Some(field); let fsel = field.to_string(); - let extras = if field == "dim_line_color" { + let full_palette_field = field.to_string(); + let extras = if field == "dim_text_fill_color" { + crate::ui::color_select::ColorExtras { + none: true, + background: true, + ..Default::default() + } + } else if field.starts_with("dim_") { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() } } else { crate::ui::color_select::ColorExtras { by_layer: false, by_block: false, + ..Default::default() } }; let selector = crate::ui::color_select::color_selector( @@ -874,7 +892,10 @@ impl PropertiesPanel { color: c, }, Message::PropColorFieldToggle(field.to_string()), - Message::PropColorFieldToggle(field.to_string()), + Message::OpenColorWindow( + crate::app::ColorPickTarget::PropertiesField(full_palette_field), + color, + ), ); return prop_row_widget(label, selector); } @@ -884,6 +905,7 @@ impl PropertiesPanel { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() }, Message::PropColorChanged, Message::PropColorPickerToggle, diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index abcccd86..9af9ff61 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1135,6 +1135,7 @@ impl Ribbon { crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() }, Message::RibbonColorChanged, Message::OpenColorWindow( diff --git a/src/ui/style/dimstyle.rs b/src/ui/style/dimstyle.rs index e6a7a200..dc2db0c6 100644 --- a/src/ui/style/dimstyle.rs +++ b/src/ui/style/dimstyle.rs @@ -518,6 +518,7 @@ pub fn view_window<'a>( crate::ui::color_select::ColorExtras { by_layer: true, by_block: true, + ..Default::default() }, move |c| Message::DsEdit(f_sel.clone(), crate::ui::color_select::color_to_aci_string(c)), Message::DsColorMore(fld.clone()), diff --git a/src/ui/style/mleaderstyle.rs b/src/ui/style/mleaderstyle.rs index 0de3aee7..4b7e5a2a 100644 --- a/src/ui/style/mleaderstyle.rs +++ b/src/ui/style/mleaderstyle.rs @@ -115,7 +115,11 @@ fn color_row<'a>( let selector = crate::ui::color_select::color_selector( current, open, - crate::ui::color_select::ColorExtras { by_layer: true, by_block: true }, + crate::ui::color_select::ColorExtras { + by_layer: true, + by_block: true, + ..Default::default() + }, move |color| Message::MLeaderStyleEdit { field, value: crate::ui::color_select::color_to_aci_string(color), diff --git a/src/ui/style/tablestyle.rs b/src/ui/style/tablestyle.rs index ce067b57..4117ec77 100644 --- a/src/ui/style/tablestyle.rs +++ b/src/ui/style/tablestyle.rs @@ -317,7 +317,11 @@ fn cell_editor<'a>(v: &TableStyleView<'a>, row_index: u8) -> Element<'a, Message let selector = crate::ui::color_select::color_selector( current, v.color_open == Some((row_index, field)), - crate::ui::color_select::ColorExtras { by_layer: true, by_block: true }, + crate::ui::color_select::ColorExtras { + by_layer: true, + by_block: true, + ..Default::default() + }, move |color| Message::TableStyleCellEdit { row: row_index, field, diff --git a/src/ui/window/layer_state_manager.rs b/src/ui/window/layer_state_manager.rs index 00429e3f..97f54f8b 100644 --- a/src/ui/window/layer_state_manager.rs +++ b/src/ui/window/layer_state_manager.rs @@ -412,6 +412,7 @@ fn editor_layer_row<'a>( crate::ui::color_select::ColorExtras { by_layer: false, by_block: false, + ..Default::default() }, move |color| Message::LayerStateEditorLayerColor(index, color), Message::LayerStateEditorLayerColorToggle(index), diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index cffcfa4a..11253ee5 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -813,6 +813,7 @@ fn layer_row<'a>( crate::ui::color_select::ColorExtras { by_layer: false, by_block: false, + ..Default::default() }, Message::LayerColorSet, Message::LayerColorPickerToggle(index), From 8bceb3ecd3faa61605b7df5c15ee3016f22a5658 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 24 Aug 2026 16:09:03 +0300 Subject: [PATCH 31/98] fix(properties): preserve XDATA details --- src/app/properties.rs | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index a51ed03a..3fad3fa4 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -27,27 +27,6 @@ fn visual_style_properties_text(style: &acadrust::objects::VisualStyle) -> Strin .join("\n") } -/// Format an XDATA value for display in the Properties panel. -fn format_xdata_value(value: &acadrust::xdata::XDataValue) -> String { - use acadrust::xdata::XDataValue; - match value { - XDataValue::String(s) => s.clone(), - XDataValue::ControlString(s) => format!("ControlString: {s}"), - XDataValue::LayerName(s) => format!("Layer: {s}"), - XDataValue::BinaryData(b) => format!("Binary: {} bytes", b.len()), - XDataValue::Handle(h) => format!("Handle: {:X}", h.value()), - XDataValue::Point3D(v) - | XDataValue::Position3D(v) - | XDataValue::Displacement3D(v) - | XDataValue::Direction3D(v) => format!("{:.6}, {:.6}, {:.6}", v.x, v.y, v.z), - XDataValue::Real(r) | XDataValue::Distance(r) | XDataValue::ScaleFactor(r) => { - format!("{r}") - } - XDataValue::Integer16(i) => format!("{i}"), - XDataValue::Integer32(i) => format!("{i}"), - } -} - impl OpenCADStudio { /// Rebuild the PropertiesPanel from the current entity selection. /// Preserves UI state (open pickers, edit buffer) across refreshes. @@ -634,7 +613,6 @@ impl OpenCADStudio { entity, )); - // Extended Data (XDATA) section: show attached app records. { use crate::entities::common::ro_prop; let xd = &source_entity.common().extended_data; @@ -644,13 +622,13 @@ impl OpenCADStudio { let value_text = rec .values .iter() - .map(format_xdata_value) + .map(|value| format!("{value:?}")) .collect::>() .join(", "); xd_props.push(ro_prop( - &rec.application_name, + t!("Application").as_ref(), "xdata_value", - value_text, + format!("{}: {value_text}", rec.application_name), )); } sections.push(crate::scene::model::object::PropSection { From 4e80afb0da696863ee1a93e1fb38148a899d2535 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:04:01 +0300 Subject: [PATCH 32/98] Show effective ring segment widths --- src/entities/lwpolyline.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index e51bc8bb..467d5cb7 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -852,8 +852,24 @@ fn properties(pline: &LwPolyline) -> Vec { let v = pline.vertices.get(vi); let vx = v.map_or(0.0, |v| v.location.x); let vy = v.map_or(0.0, |v| v.location.y); - let start_w = v.map_or(0.0, |v| v.start_width); - let end_w = v.map_or(0.0, |v| v.end_width); + // A constant width is the effective width of every segment whose + // per-vertex value is not overridden. Showing the raw zero stored on + // those vertices made wide polylines (notably rings) claim that their + // visible segment width was zero even though the global width was active. + let start_w = v.map_or(0.0, |v| { + if v.start_width.abs() > 1.0e-12 { + v.start_width + } else { + pline.constant_width + } + }); + let end_w = v.map_or(0.0, |v| { + if v.end_width.abs() > 1.0e-12 { + v.end_width + } else { + pline.constant_width + } + }); let mp = ::mass_props(pline); let cloud_arc_length = revision_cloud_arc_length(pline); let vertex_label = if n == 0 { From 95279d10c0e9ef09689ea21d5a9f50536f480bf2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 24 Aug 2026 18:09:34 +0300 Subject: [PATCH 33/98] Align table properties and editing behavior --- Cargo.lock | 2 +- Cargo.toml | 5 +- src/app/properties.rs | 391 ++++++++- src/app/update/command.rs | 41 + src/app/update/mod.rs | 58 ++ src/app/update/viewport.rs | 59 +- src/entities/table.rs | 1309 ++++++++++++++++++++++++----- src/modules/annotate/table_cmd.rs | 1 + 8 files changed, 1630 insertions(+), 236 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 189c1b22..d084c898 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd" +source = "git+https://github.com/ramox81/cadcodec.git?rev=1fa0a5e#1fa0a5e22f53dd45c6b0b9d8c63f7fc7730a3a8f" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 4cc5ae75..3a5bc5f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "94df2c3", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1fa0a5e", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "76de3b3", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -60,6 +60,9 @@ ashpd = { version = "0.13.13", default-features = false, features = ["async-io", iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } +[patch."https://github.com/HakanSeven12/cadcodec.git"] +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1fa0a5e" } + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } meshopt = "0.6.2" diff --git a/src/app/properties.rs b/src/app/properties.rs index 9f81392a..bda212fc 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -144,6 +144,7 @@ impl OpenCADStudio { }; crate::scene::view::dispatch::set_prop_current_vertex(prop_vertex); crate::entities::table::set_prop_current_cell(prop_vertex); + crate::entities::table::set_prop_current_cell_active(prop_vertex_indicator_active); let annotation_scale_handle = self.tabs[i].scene.displayed_annotation_scale_handle(); let new_panel = { @@ -1299,6 +1300,10 @@ impl OpenCADStudio { } } acadrust::EntityType::Table(table) => { + use acadrust::entities::table::{ + CellEdgeFlags, CellStylePropertyFlags, CellValueType, + }; + let mut names: Vec = doc .objects .values() @@ -1313,15 +1318,17 @@ impl OpenCADStudio { .collect(); names.sort_by_key(|name| name.to_ascii_lowercase()); names.dedup_by(|a, b| a.eq_ignore_ascii_case(b)); - let selected = table + let selected_style = table .table_style_handle .and_then(|handle| doc.objects.get(&handle)) .and_then(|object| match object { acadrust::objects::ObjectType::TableStyle(style) => { - Some(style.name.clone()) + Some(style) } _ => None, - }) + }); + let selected = selected_style + .map(|style| style.name.clone()) .unwrap_or_else(|| "Standard".to_string()); if !names.iter().any(|name| name.eq_ignore_ascii_case(&selected)) { names.insert(0, selected.clone()); @@ -1334,6 +1341,311 @@ impl OpenCADStudio { options: names, }, ); + + let title_suppressed = + crate::entities::table::resolved_title_suppressed( + table, + selected_style, + ); + let header_suppressed = + crate::entities::table::resolved_header_suppressed( + table, + selected_style, + ); + let flow_up = crate::entities::table::resolved_flow_up( + table, + selected_style, + ); + let (horizontal_margin, vertical_margin) = + crate::entities::table::resolved_table_margins( + table, + selected_style, + ); + update_row_toggle( + &mut sections, + "tbl_title_suppressed", + title_suppressed, + ); + update_row_toggle( + &mut sections, + "tbl_header_suppressed", + header_suppressed, + ); + update_row_text( + &mut sections, + "tbl_flow_direction", + if flow_up { "Up" } else { "Down" }.to_string(), + ); + update_row_text( + &mut sections, + "tbl_horizontal_margin", + crate::entities::common::format_length(horizontal_margin), + ); + update_row_text( + &mut sections, + "tbl_vertical_margin", + crate::entities::common::format_length(vertical_margin), + ); + + let columns = table.column_count(); + if columns > 0 { + let cell_index = prop_vertex.min( + table.row_count() + .saturating_mul(columns) + .saturating_sub(1), + ); + let row_index = cell_index / columns; + let column_index = cell_index % columns; + if let (Some(row), Some(cell)) = ( + table.rows.get(row_index), + table.cell(row_index, column_index), + ) { + let document_row_style = selected_style.map(|style| { + let kind = match ( + title_suppressed, + header_suppressed, + row_index, + ) { + (false, _, 0) => 0, + (false, false, 1) | (true, false, 0) => 1, + _ => 2, + }; + match kind { + 0 => &style.title_row_style, + 1 => &style.header_row_style, + _ => &style.data_row_style, + } + }); + let local = |property| { + crate::entities::table::style_for_property( + table, + row, + column_index, + cell, + property, + ) + }; + let alignment = local(CellStylePropertyFlags::ALIGNMENT) + .map(|style| style.alignment) + .or_else(|| { + document_row_style.map(|style| style.alignment as i32) + }) + .unwrap_or(5); + let alignment = match alignment { + 1 => "Top Left", + 2 => "Top Center", + 3 => "Top Right", + 4 => "Middle Left", + 6 => "Middle Right", + 7 => "Bottom Left", + 8 => "Bottom Center", + 9 => "Bottom Right", + _ => "Middle Center", + }; + update_row_text( + &mut sections, + "tbl_cell_alignment", + alignment.to_string(), + ); + + let text_style_name = local(CellStylePropertyFlags::TEXT_STYLE) + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + .or_else(|| { + document_row_style + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + }) + .unwrap_or_else(|| "Standard".to_string()); + update_row_text( + &mut sections, + "tbl_cell_text_style", + text_style_name, + ); + let text_height = local(CellStylePropertyFlags::TEXT_HEIGHT) + .map(|style| style.text_height) + .or_else(|| { + document_row_style.map(|style| style.text_height) + }) + .unwrap_or(0.18); + update_row_text( + &mut sections, + "tbl_cell_text_height", + crate::entities::common::format_length(text_height), + ); + let content_color = local( + CellStylePropertyFlags::CONTENT_COLOR, + ) + .map(|style| style.content_color) + .or_else(|| { + document_row_style.map(|style| style.text_color) + }) + .unwrap_or(acadrust::types::Color::ByBlock); + update_row_color( + &mut sections, + "tbl_cell_content_color", + content_color, + ); + let background_style = + local(CellStylePropertyFlags::BACKGROUND_COLOR); + let background_color = background_style + .map(|style| style.background_color) + .or_else(|| { + document_row_style.map(|style| style.fill_color) + }) + .unwrap_or(acadrust::types::Color::ByBlock); + update_row_color( + &mut sections, + "tbl_cell_background_color", + background_color, + ); + update_row_toggle( + &mut sections, + "tbl_cell_fill", + background_style + .map(|style| style.fill_enabled) + .or_else(|| { + document_row_style + .map(|style| style.fill_enabled) + }) + .unwrap_or(false), + ); + let format = local(CellStylePropertyFlags::DATA_FORMAT) + .map(|style| style.value_format.clone()) + .or_else(|| { + document_row_style + .map(|style| style.format_string.clone()) + }) + .unwrap_or_default(); + update_row_text( + &mut sections, + "tbl_cell_format", + format, + ); + let data_type = cell + .contents + .first() + .map(|content| content.value.value_type) + .filter(|kind| *kind != CellValueType::Unknown) + .or_else(|| { + local(CellStylePropertyFlags::DATA_TYPE) + .map(|style| { + CellValueType::from( + style.value_data_type.max(0) as u32, + ) + }) + }) + .or_else(|| { + document_row_style.map(|style| { + CellValueType::from(style.data_type.max(0) as u32) + }) + }) + .unwrap_or(CellValueType::String); + let data_type = match data_type { + CellValueType::Long => "Integer", + CellValueType::Double => "Decimal", + CellValueType::Date => "Date", + CellValueType::Point2D => "Point 2D", + CellValueType::Point3D => "Point 3D", + CellValueType::Handle => "Handle", + _ => "Text", + }; + update_row_text( + &mut sections, + "tbl_cell_data_type", + data_type.to_string(), + ); + for (field, property, fallback) in [ + ( + "tbl_cell_margin_left", + CellStylePropertyFlags::MARGIN_LEFT, + horizontal_margin, + ), + ( + "tbl_cell_margin_top", + CellStylePropertyFlags::MARGIN_TOP, + vertical_margin, + ), + ( + "tbl_cell_margin_right", + CellStylePropertyFlags::MARGIN_RIGHT, + horizontal_margin, + ), + ( + "tbl_cell_margin_bottom", + CellStylePropertyFlags::MARGIN_BOTTOM, + vertical_margin, + ), + ] { + let value = local(property) + .map(|style| match field { + "tbl_cell_margin_left" => style.margin_left, + "tbl_cell_margin_top" => style.margin_top, + "tbl_cell_margin_right" => style.margin_right, + _ => style.margin_bottom, + }) + .unwrap_or(fallback); + update_row_text( + &mut sections, + field, + crate::entities::common::format_length(value), + ); + } + + let border_visible = |edge: CellEdgeFlags| { + let local_style = [ + cell.style.as_ref(), + row.style.as_ref(), + table + .columns + .get(column_index) + .and_then(|column| column.style.as_ref()), + table.base_style.as_ref(), + ] + .into_iter() + .flatten() + .find(|style| { + style.applied_border_edges.contains(edge) + }); + if let Some(style) = local_style { + return if edge == CellEdgeFlags::TOP { + !style.top_border.invisible + } else if edge == CellEdgeFlags::RIGHT { + !style.right_border.invisible + } else if edge == CellEdgeFlags::BOTTOM { + !style.bottom_border.invisible + } else { + !style.left_border.invisible + }; + } + document_row_style + .map(|style| { + if edge == CellEdgeFlags::TOP { + !style.top_border.is_invisible + } else if edge == CellEdgeFlags::RIGHT { + !style.right_border.is_invisible + } else if edge == CellEdgeFlags::BOTTOM { + !style.bottom_border.is_invisible + } else { + !style.left_border.is_invisible + } + }) + .unwrap_or(true) + }; + for (field, edge) in [ + ("tbl_cell_border_top", CellEdgeFlags::TOP), + ("tbl_cell_border_right", CellEdgeFlags::RIGHT), + ("tbl_cell_border_bottom", CellEdgeFlags::BOTTOM), + ("tbl_cell_border_left", CellEdgeFlags::LEFT), + ] { + update_row_toggle( + &mut sections, + field, + border_visible(edge), + ); + } + } + } } _ => {} } @@ -2469,6 +2781,79 @@ fn set_row_value( } } +fn update_row_text( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + value: String, +) { + use crate::scene::model::object::PropValue; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::ReadOnly(current) + | PropValue::EditText(current) + | PropValue::PlainText(current) => *current = value, + PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = value, + PropValue::Choice { selected, .. } => *selected = value, + _ => {} + } + return; + } +} + +fn update_row_toggle( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + value: bool, +) { + use crate::scene::model::object::PropValue; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::BoolToggle { value: current, .. } => *current = value, + PropValue::ReadOnly(current) => { + *current = if value { t!("Yes") } else { t!("No") }.into_owned() + } + PropValue::ReadOnlyWithTooltip { value: current, .. } => { + *current = if value { t!("Yes") } else { t!("No") }.into_owned() + } + _ => {} + } + return; + } +} + +fn update_row_color( + sections: &mut [crate::scene::model::object::PropSection], + field: &str, + color: acadrust::types::Color, +) { + use crate::scene::model::object::PropValue; + let label = match color { + acadrust::types::Color::None => "None".to_string(), + acadrust::types::Color::ByLayer => "ByLayer".to_string(), + acadrust::types::Color::ByBlock => "ByBlock".to_string(), + acadrust::types::Color::Index(index) => index.to_string(), + acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), + }; + for section in sections.iter_mut() { + let Some(row) = section.props.iter_mut().find(|property| property.field == field) else { + continue; + }; + match &mut row.value { + PropValue::ColorChoice(current) => *current = color, + PropValue::ReadOnly(current) => *current = label, + PropValue::ReadOnlyWithTooltip { value: current, .. } => *current = label, + _ => {} + } + return; + } +} + /// The lineweight dropdown options (named defaults + the standard millimetre /// steps), matching the labels `dim_lineweight_label` produces. pub(crate) fn lineweight_options() -> Vec { diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 2a74327b..8bb039e5 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2109,6 +2109,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].scene.bump_entities(&changes); } } + } else if field == "tbl_style_handle" { + let style_handle = self.tabs[i] + .scene + .document + .objects + .iter() + .find_map(|(handle, object)| match object { + acadrust::objects::ObjectType::TableStyle(style) + if style.name.eq_ignore_ascii_case(value.trim()) => + { + Some(*handle) + } + _ => None, + }); + if let Some(style_handle) = style_handle { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.table_style_handle = Some(style_handle); + } + } + } } else if field == "transparency" { for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { @@ -2248,6 +2271,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; self.tabs[i].properties.edit_choice_open = false; @@ -2555,6 +2587,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; self.refresh_properties(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index c37894f1..43e611a1 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4651,6 +4651,47 @@ impl OpenCADStudio { } } } + "tbl_title_suppressed" | "tbl_header_suppressed" => { + let next = { + let document = &self.tabs[i].scene.document; + let Some(acadrust::EntityType::Table(table)) = + document.get_entity(handle) + else { + continue; + }; + let table_style = table.table_style_handle.and_then(|style_handle| { + document.objects.get(&style_handle).and_then(|object| { + match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } + _ => None, + } + }) + }); + let current = if field == "tbl_title_suppressed" { + crate::entities::table::resolved_title_suppressed( + table, + table_style, + ) + } else { + crate::entities::table::resolved_header_suppressed( + table, + table_style, + ) + }; + !current + }; + if let Some(entity) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + crate::scene::view::dispatch::apply_geom_prop( + entity, + field, + if next { "true" } else { "false" }, + ); + } + } _ => { if let Some(entity) = self.tabs[i].scene.document.get_entity_mut(handle) @@ -4662,6 +4703,15 @@ impl OpenCADStudio { } } } + if field.starts_with("tbl_") { + for &handle in &handles { + if let Some(acadrust::EntityType::Table(table)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + table.block_record_handle = None; + } + } + } self.invalidate_property_targets(i, &handles); self.tabs[i].dirty = true; self.refresh_properties(); @@ -4889,6 +4939,13 @@ impl OpenCADStudio { cell_index / columns, cell_index % columns, ) { + use acadrust::entities::table::CellStateFlags; + if cell.state.intersects( + CellStateFlags::FORMAT_LOCKED + | CellStateFlags::FORMAT_READ_ONLY, + ) { + continue; + } let style = cell.style.get_or_insert_with(Default::default); if field == "tbl_cell_content_color" { style.content_color = color; @@ -4903,6 +4960,7 @@ impl OpenCADStudio { ); } } + table.block_record_handle = None; } self.invalidate_property_targets(i, &handles); self.tabs[i].properties.open_color_field = None; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 899a3139..0ff0ea22 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4412,25 +4412,23 @@ impl OpenCADStudio { let mut down = horizontal .cross(normal) .normalize_or(glam::DVec3::NEG_Y); - let flows_up = table - .table_style_handle - .and_then(|style_handle| { - self.tabs[i] - .scene - .document - .objects - .get(&style_handle) - }) - .is_some_and(|object| { - matches!( - object, - acadrust::objects::ObjectType::TableStyle(style) - if matches!( - style.flow_direction, - acadrust::objects::TableFlowDirection::Up - ) - ) - }); + let table_style = table.table_style_handle.and_then(|style_handle| { + self.tabs[i] + .scene + .document + .objects + .get(&style_handle) + .and_then(|object| match object { + acadrust::objects::ObjectType::TableStyle(style) => { + Some(style) + } + _ => None, + }) + }); + let flows_up = crate::entities::table::resolved_flow_up( + table, + table_style, + ); if flows_up { down = -down; } @@ -4461,10 +4459,19 @@ impl OpenCADStudio { Some(*offset) }) .position(|end| y <= end)?; + let locked = table.cell(row, column).is_some_and(|cell| { + use acadrust::entities::table::CellStateFlags; + cell.state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY, + ) + }); Some(( - crate::modules::annotate::table_cmd::TableCellEditCommand::new( - handle, table, row, column, - ), + (!locked).then(|| { + crate::modules::annotate::table_cmd::TableCellEditCommand::new( + handle, table, row, column, + ) + }), row * table.column_count() + column, )) } @@ -4475,9 +4482,11 @@ impl OpenCADStudio { self.tabs[i].properties.prop_vertex_indicator_active = true; crate::entities::table::set_prop_current_cell(cell_index); self.refresh_properties(); - self.command_line - .push_info(&crate::command::CadCommand::prompt(&command)); - self.tabs[i].active_cmd = Some(Box::new(command)); + if let Some(command) = command { + self.command_line + .push_info(&crate::command::CadCommand::prompt(&command)); + self.tabs[i].active_cmd = Some(Box::new(command)); + } return Task::none(); } // Any text-bearing entity opens its in-place editor diff --git a/src/entities/table.rs b/src/entities/table.rs index c967d0d6..d705f9fe 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -15,16 +15,25 @@ use crate::t; thread_local! { static PROPERTY_CELL: std::cell::Cell = const { std::cell::Cell::new(0) }; + static PROPERTY_CELL_ACTIVE: std::cell::Cell = const { std::cell::Cell::new(false) }; } pub fn set_prop_current_cell(index: usize) { PROPERTY_CELL.with(|cell| cell.set(index)); } +pub fn set_prop_current_cell_active(active: bool) { + PROPERTY_CELL_ACTIVE.with(|cell| cell.set(active)); +} + fn prop_current_cell() -> usize { PROPERTY_CELL.with(std::cell::Cell::get) } +fn prop_current_cell_active() -> bool { + PROPERTY_CELL_ACTIVE.with(std::cell::Cell::get) +} + fn v3(v: &acadrust::types::Vector3) -> Vec3 { Vec3::new(v.x as f32, v.y as f32, v.z as f32) } @@ -78,7 +87,7 @@ fn merged_owner_and_span( Some((row, column, row, column)) } -fn style_for_property<'a>( +pub(crate) fn style_for_property<'a>( table: &'a Table, row: &'a acadrust::entities::table::TableRow, column: usize, @@ -89,7 +98,12 @@ fn style_for_property<'a>( .columns .get(column) .and_then(|column| column.style.as_ref()); - for style in [cell.style.as_ref(), row.style.as_ref(), column_style] + for style in [ + cell.style.as_ref(), + row.style.as_ref(), + column_style, + table.base_style.as_ref(), + ] .into_iter() .flatten() { @@ -97,12 +111,99 @@ fn style_for_property<'a>( return Some(style); } } - table.base_style.as_ref().or_else(|| { - [cell.style.as_ref(), row.style.as_ref(), column_style] - .into_iter() - .flatten() - .next() - }) + None +} + +fn style_for_border<'a>( + table: &'a Table, + row: &'a acadrust::entities::table::TableRow, + column: usize, + cell: &'a acadrust::entities::table::TableCell, + edge: acadrust::entities::table::CellEdgeFlags, +) -> Option<&'a acadrust::entities::table::CellStyle> { + let column_style = table + .columns + .get(column) + .and_then(|column| column.style.as_ref()); + [ + cell.style.as_ref(), + row.style.as_ref(), + column_style, + table.base_style.as_ref(), + ] + .into_iter() + .flatten() + .find(|style| style.applied_border_edges.contains(edge)) +} + +pub(crate) fn resolved_title_suppressed( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + table + .legacy_style_override + .as_ref() + .and_then(|style| style.title_suppressed) + .or_else(|| table_style.map(|style| style.title_suppressed)) + .unwrap_or(false) +} + +pub(crate) fn resolved_header_suppressed( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + table + .legacy_style_override + .as_ref() + .and_then(|style| style.header_suppressed) + .or_else(|| table_style.map(|style| style.header_suppressed)) + .unwrap_or(false) +} + +pub(crate) fn resolved_flow_up( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> bool { + use acadrust::entities::table::CellStylePropertyFlags; + + table + .legacy_style_override + .as_ref() + .and_then(|style| style.flow_direction) + .map(|flow| flow != 0) + .or_else(|| { + table.base_style.as_ref().and_then(|style| { + style + .property_flags + .contains(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP) + .then_some(true) + }) + }) + .unwrap_or_else(|| { + matches!( + table_style.map(|style| style.flow_direction), + Some(acadrust::objects::TableFlowDirection::Up) + ) + }) +} + +pub(crate) fn resolved_table_margins( + table: &Table, + table_style: Option<&acadrust::objects::TableStyle>, +) -> (f64, f64) { + let horizontal = table + .legacy_style_override + .as_ref() + .and_then(|style| style.horizontal_cell_margin) + .or_else(|| table_style.map(|style| style.horizontal_margin)) + .unwrap_or(0.0); + let vertical = table + .legacy_style_override + .as_ref() + .and_then(|style| style.vertical_cell_margin) + .or_else(|| table_style.map(|style| style.vertical_margin)) + .unwrap_or(0.0); + (horizontal, vertical) } fn table_offsets(table: &Table, scale: f32) -> (Vec, Vec) { @@ -120,40 +221,68 @@ fn table_offsets(table: &Table, scale: f32) -> (Vec, Vec) { (columns, rows) } -fn break_frame_for_row( +#[derive(Clone, Copy)] +struct TableBreakSegment { + start_row: usize, + end_row: usize, + origin: Vec3, +} + +fn table_break_segments( table: &Table, - row: usize, h: Vec3, down: Vec3, row_offsets: &[f32], scale: f32, -) -> (Vec3, f32) { +) -> Vec { use acadrust::entities::table::BreakOptionFlags; let insertion = v3(&table.insertion_point); - let offset_to_world = - |offset: &acadrust::types::Vector3| insertion + v3(offset); + if table.rows.is_empty() { + return Vec::new(); + } if !table.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) { - return (insertion, row_offsets.get(row).copied().unwrap_or(0.0)); + return vec![TableBreakSegment { + start_row: 0, + end_row: table.rows.len() - 1, + origin: insertion, + }]; } - if let Some(range) = table + let offset_to_world = |offset: &acadrust::types::Vector3| insertion + v3(offset); + let mut cached: Vec<_> = table .break_ranges .iter() - .find(|range| row as i32 >= range.start_row && row as i32 <= range.end_row) - { - let start = range.start_row.max(0) as usize; - let position = offset_to_world(&range.position); - let origin = if position.is_finite() { - position - } else { - insertion - }; - let top = row_offsets.get(row).copied().unwrap_or(0.0) - - row_offsets.get(start).copied().unwrap_or(0.0); - return (origin, top); + .filter_map(|range| { + let start = range.start_row.max(0) as usize; + let end = range.end_row.max(0) as usize; + (start <= end && end < table.rows.len()).then(|| TableBreakSegment { + start_row: start, + end_row: end, + origin: { + let position = offset_to_world(&range.position); + if position.is_finite() { + position + } else { + insertion + } + }, + }) + }) + .collect(); + cached.sort_by_key(|segment| segment.start_row); + let cached_complete = cached.first().is_some_and(|segment| segment.start_row == 0) + && cached + .last() + .is_some_and(|segment| segment.end_row + 1 == table.rows.len()) + && cached + .windows(2) + .all(|pair| pair[0].end_row + 1 == pair[1].start_row); + if cached_complete { + return cached; } + let mut segments = Vec::new(); let mut start_row = 0usize; let mut segment = 0usize; while start_row < table.rows.len() { @@ -173,48 +302,154 @@ fn break_frame_for_row( { end_row += 1; } - if row <= end_row { - let manual_positions = table - .break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); - let manual_origin = manual_positions - .then(|| table.break_data.get(segment)) - .flatten() - .map(|data| offset_to_world(&data.position)) - .filter(|position| position.is_finite()); - let origin = manual_origin.unwrap_or_else(|| { - let spacing = table.break_spacing as f32 * scale; - let horizontal_step = - table.total_width() as f32 * scale + spacing; - let vertical_step = if max_height.is_finite() { - max_height + spacing - } else { - table.total_height() as f32 * scale + spacing - }; - match table.break_flow_direction { - acadrust::entities::table::BreakFlowDirection::Left => { - insertion - h * segment as f32 * horizontal_step - } - acadrust::entities::table::BreakFlowDirection::Vertical => { - insertion + down * segment as f32 * vertical_step - } - acadrust::entities::table::BreakFlowDirection::Right => { - insertion + h * segment as f32 * horizontal_step - } + let manual_positions = table + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); + let manual_origin = manual_positions + .then(|| table.break_data.get(segment)) + .flatten() + .map(|data| offset_to_world(&data.position)) + .filter(|position| position.is_finite()); + let origin = manual_origin.unwrap_or_else(|| { + let spacing = table.break_spacing as f32 * scale; + let horizontal_step = table.total_width() as f32 * scale + spacing; + let vertical_step = if max_height.is_finite() { + max_height + spacing + } else { + table.total_height() as f32 * scale + spacing + }; + match table.break_flow_direction { + acadrust::entities::table::BreakFlowDirection::Left => { + insertion - h * segment as f32 * horizontal_step } - }); - return ( - origin, - row_offsets.get(row).copied().unwrap_or(0.0) - start_offset, - ); - } + acadrust::entities::table::BreakFlowDirection::Vertical => { + insertion + down * segment as f32 * vertical_step + } + acadrust::entities::table::BreakFlowDirection::Right => { + insertion + h * segment as f32 * horizontal_step + } + } + }); + segments.push(TableBreakSegment { + start_row, + end_row, + origin, + }); start_row = end_row.saturating_add(1); segment = segment.saturating_add(1); } + segments +} + +fn break_frame_for_row( + table: &Table, + row: usize, + h: Vec3, + down: Vec3, + row_offsets: &[f32], + scale: f32, +) -> (Vec3, f32) { + let insertion = v3(&table.insertion_point); + if let Some(segment) = table_break_segments(table, h, down, row_offsets, scale) + .into_iter() + .find(|segment| row >= segment.start_row && row <= segment.end_row) + { + return ( + segment.origin, + row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets + .get(segment.start_row) + .copied() + .unwrap_or(0.0), + ); + } + (insertion, row_offsets.get(row).copied().unwrap_or(0.0)) } +fn break_frames_for_row( + table: &Table, + row: usize, + h: Vec3, + down: Vec3, + row_offsets: &[f32], + scale: f32, + top_label_rows: usize, + bottom_label_rows: usize, +) -> Vec<(Vec3, f32)> { + use acadrust::entities::table::BreakOptionFlags; + + let segments = table_break_segments(table, h, down, row_offsets, scale); + let Some((segment_index, segment)) = segments + .iter() + .enumerate() + .find(|(_, segment)| row >= segment.start_row && row <= segment.end_row) + else { + return vec![break_frame_for_row(table, row, h, down, row_offsets, scale)]; + }; + let repeat_top = table + .break_options + .contains(BreakOptionFlags::REPEAT_TOP_LABELS) + && top_label_rows > 0; + let repeat_bottom = table + .break_options + .contains(BreakOptionFlags::REPEAT_BOTTOM_LABELS) + && bottom_label_rows > 0; + let top_label_rows = top_label_rows.min(table.rows.len()); + let bottom_label_rows = bottom_label_rows.min(table.rows.len()); + let bottom_start = table.rows.len().saturating_sub(bottom_label_rows); + let top_height = row_offsets + .get(top_label_rows) + .copied() + .unwrap_or(0.0); + let mut primary_top = row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets + .get(segment.start_row) + .copied() + .unwrap_or(0.0); + if repeat_top && segment_index > 0 { + primary_top += top_height; + } + let mut frames = vec![(segment.origin, primary_top)]; + + if repeat_top && row < top_label_rows { + for repeated_segment in segments.iter().skip(1) { + frames.push(( + repeated_segment.origin, + row_offsets.get(row).copied().unwrap_or(0.0), + )); + } + } + if repeat_bottom && row >= bottom_start { + let label_offset = row_offsets.get(row).copied().unwrap_or(0.0) + - row_offsets.get(bottom_start).copied().unwrap_or(0.0); + for (index, repeated_segment) in segments + .iter() + .enumerate() + .take(segments.len().saturating_sub(1)) + { + let content_height = row_offsets + .get(repeated_segment.end_row + 1) + .copied() + .unwrap_or(0.0) + - row_offsets + .get(repeated_segment.start_row) + .copied() + .unwrap_or(0.0); + let repeated_top = content_height + + if repeat_top && index > 0 { + top_height + } else { + 0.0 + } + + label_offset; + frames.push((repeated_segment.origin, repeated_top)); + } + } + frames +} + fn format_cell_value(value: &acadrust::entities::table::CellValue) -> String { let display = value.display(); if !display.is_empty() { @@ -484,10 +719,7 @@ pub(crate) fn block_cell_inserts( _ => None, }) }); - let flow = if matches!( - table_style.map(|style| style.flow_direction), - Some(acadrust::objects::TableFlowDirection::Up) - ) { + let flow = if resolved_flow_up(table, table_style) { -down } else { down @@ -915,8 +1147,8 @@ impl RenderConvertible for Table { _ => None, }) }); - let title_suppressed = table_style.map(|t| t.title_suppressed).unwrap_or(false); - let header_suppressed = table_style.map(|t| t.header_suppressed).unwrap_or(false); + let title_suppressed = resolved_title_suppressed(self, table_style); + let header_suppressed = resolved_header_suppressed(self, table_style); let font_for_handle = |handle: Option| -> Option { handle.and_then(|h| lookup_style(h)).and_then(|s| { @@ -1179,20 +1411,19 @@ pub fn tessellate_table( _ => None, }) }); - let flow_up = matches!( - table_style.map(|t| t.flow_direction), - Some(acadrust::objects::TableFlowDirection::Up) - ); + let flow_up = resolved_flow_up(tab, table_style); let v_flow = if flow_up { -v_down } else { v_down }; let (col_offsets, row_offsets) = table_offsets(tab, anno_scale); - let title_suppressed = table_style.map(|t| t.title_suppressed).unwrap_or(false); - let header_suppressed = table_style.map(|t| t.header_suppressed).unwrap_or(false); - let h_margin = table_style - .map(|t| t.horizontal_margin as f32) - .unwrap_or(0.0) * anno_scale; - let v_margin = table_style.map(|t| t.vertical_margin as f32).unwrap_or(0.0) * anno_scale; + let title_suppressed = resolved_title_suppressed(tab, table_style); + let header_suppressed = resolved_header_suppressed(tab, table_style); + let top_label_rows = (usize::from(!title_suppressed) + usize::from(!header_suppressed)) + .min(tab.rows.len()); + let bottom_label_rows = usize::from(!tab.rows.is_empty()); + let (horizontal_margin, vertical_margin) = resolved_table_margins(tab, table_style); + let h_margin = horizontal_margin as f32 * anno_scale; + let v_margin = vertical_margin as f32 * anno_scale; let lookup_style = |hh: acadrust::Handle| -> Option<&acadrust::tables::TextStyle> { document.text_styles.iter().find(|s| s.handle == hh) @@ -1287,8 +1518,17 @@ pub fn tessellate_table( else { continue; }; - let (origin, row_top) = - break_frame_for_row(tab, ri, h, v_flow, &row_offsets, anno_scale); + let frames = break_frames_for_row( + tab, + ri, + h, + v_flow, + &row_offsets, + anno_scale, + top_label_rows, + bottom_label_rows, + ); + for (origin, row_top) in frames { let merged_height = row_offsets .get(row_end + 1) .copied() @@ -1306,13 +1546,6 @@ pub fn tessellate_table( let tr = origin + h * col_right + v_flow * row_top; let br_ = origin + h * col_right + v_flow * row_bot; let bl = origin + h * col_left + v_flow * row_bot; - let cell_style = cell - .style - .as_ref() - .or(row.style.as_ref()) - .or_else(|| tab.columns.get(ci).and_then(|column| column.style.as_ref())) - .or(tab.base_style.as_ref()); - // ── Fill ────────────────────────────────────────────────────── let fill_style = style_for_property( tab, @@ -1342,7 +1575,13 @@ pub fn tessellate_table( // ── Borders (per edge: cell override → row style → default) ─── // (top, right, bottom, left) let edge = |which: u8| -> (bool, [f32; 4], f32) { - if let Some(cs) = cell_style { + let edge_flag = match which { + 0 => acadrust::entities::table::CellEdgeFlags::TOP, + 1 => acadrust::entities::table::CellEdgeFlags::RIGHT, + 2 => acadrust::entities::table::CellEdgeFlags::BOTTOM, + _ => acadrust::entities::table::CellEdgeFlags::LEFT, + }; + if let Some(cs) = style_for_border(tab, row, ci, cell, edge_flag) { let b = match which { 0 => &cs.top_border, 1 => &cs.right_border, @@ -1737,6 +1976,7 @@ pub fn tessellate_table( } } } + } } } @@ -1936,20 +2176,88 @@ impl Grippable for Table { } impl PropertyEditable for Table { - fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { + fn geometry_properties(&self, text_style_names: &[String]) -> Vec { use crate::entities::common::edit_prop as edit; - use acadrust::entities::table::BreakOptionFlags; - let toggle = |label: &str, field: &'static str, b: bool| -> Property { + use acadrust::entities::table::{BreakOptionFlags, CellStateFlags, CellStylePropertyFlags}; + let bool_text = |value: bool| if value { t!("Yes") } else { t!("No") }.into_owned(); + let toggle = |label: &str, field: &'static str, value: bool, enabled: bool| -> Property { Property { label: label.into(), field, - value: PropValue::BoolToggle { field, value: b }, + value: if enabled { + PropValue::BoolToggle { field, value } + } else { + PropValue::ReadOnly(bool_text(value)) + }, + } + }; + let choice = |label: &str, + field: &'static str, + selected: String, + options: Vec, + enabled: bool| + -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::Choice { selected, options } + } else { + PropValue::ReadOnly(selected) + }, + } + }; + let number = |label: &str, field: &'static str, value: f64, enabled: bool| -> Property { + if enabled { + edit(label, field, value) + } else { + ro(label, field, crate::entities::common::format_length(value)) + } + }; + let text = |label: &str, field: &'static str, value: String, enabled: bool| -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::PlainText(value) + } else { + PropValue::ReadOnly(value) + }, + } + }; + let color_text = |color: acadrust::types::Color| match color { + acadrust::types::Color::None => "None".to_string(), + acadrust::types::Color::ByLayer => "ByLayer".to_string(), + acadrust::types::Color::ByBlock => "ByBlock".to_string(), + acadrust::types::Color::Index(index) => index.to_string(), + acadrust::types::Color::Rgb { r, g, b } => format!("{r},{g},{b}"), + }; + let color = |label: &str, + field: &'static str, + value: acadrust::types::Color, + enabled: bool| + -> Property { + Property { + label: label.into(), + field, + value: if enabled { + PropValue::ColorChoice(value) + } else { + PropValue::ReadOnly(color_text(value)) + }, } }; // Direction = angle of the horizontal direction vector in the XY plane. let direction_deg = (self.horizontal_direction.y.atan2(self.horizontal_direction.x)).to_degrees(); let break_height = self.break_data.first().map(|data| data.height).unwrap_or(0.0); + let breaks_enabled = self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS); + let manual_positions = self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS); + let manual_heights = self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS); let cell_count = self.rows.len().saturating_mul(self.columns.len()); let cell_index = prop_current_cell().min(cell_count.saturating_sub(1)); let cell_row = if self.columns.is_empty() { @@ -1962,9 +2270,22 @@ impl PropertyEditable for Table { } else { cell_index % self.columns.len() }; + let table_row = self.rows.get(cell_row); let cell = self.cell(cell_row, cell_column); - let cell_style = cell.and_then(|cell| cell.style.as_ref()); - let alignment = match cell_style.map(|style| style.alignment).unwrap_or(5) { + let row_style = table_row.and_then(|row| row.style.as_ref()); + let column_style = self + .columns + .get(cell_column) + .and_then(|column| column.style.as_ref()); + let style_for = |property| { + table_row.and_then(|row| { + cell.and_then(|cell| { + style_for_property(self, row, cell_column, cell, property) + }) + }) + }; + let alignment_style = style_for(CellStylePropertyFlags::ALIGNMENT); + let alignment = match alignment_style.map(|style| style.alignment).unwrap_or(5) { 1 => "Top Left", 2 => "Top Center", 3 => "Top Right", @@ -1975,36 +2296,210 @@ impl PropertyEditable for Table { 9 => "Bottom Right", _ => "Middle Center", }; - let cell_locked = cell.is_some_and(|cell| { - use acadrust::entities::table::CellStateFlags; - cell.state.intersects( - CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, - ) - }); + let state = cell.map(|cell| cell.state).unwrap_or_default(); + let content_editable = !state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, + ); + let format_editable = !state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ); + let immutable_lock = state.intersects( + CellStateFlags::CONTENT_READ_ONLY | CellStateFlags::FORMAT_READ_ONLY, + ); + let cell_locked = state.intersects( + CellStateFlags::CONTENT_LOCKED + | CellStateFlags::CONTENT_READ_ONLY + | CellStateFlags::FORMAT_LOCKED + | CellStateFlags::FORMAT_READ_ONLY, + ); + let table_flow_up = self + .legacy_style_override + .as_ref() + .and_then(|style| style.flow_direction) + .map(|flow| flow != 0) + .unwrap_or_else(|| { + self.base_style.as_ref().is_some_and(|style| { + style + .property_flags + .contains(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP) + }) + }); + let legacy = self.legacy_style_override.as_ref(); + let title_suppressed = legacy + .and_then(|style| style.title_suppressed) + .unwrap_or(false); + let header_suppressed = legacy + .and_then(|style| style.header_suppressed) + .unwrap_or(false); + let horizontal_margin = legacy + .and_then(|style| style.horizontal_cell_margin) + .or_else(|| self.base_style.as_ref().map(|style| style.margin_left)) + .unwrap_or(0.06); + let vertical_margin = legacy + .and_then(|style| style.vertical_cell_margin) + .or_else(|| self.base_style.as_ref().map(|style| style.margin_top)) + .unwrap_or(0.06); + let uniform_column_width = self.columns.first().map(|column| column.width).unwrap_or(0.0); + let columns_uniform = self + .columns + .iter() + .all(|column| (column.width - uniform_column_width).abs() <= 1.0e-9); + let uniform_row_height = self.rows.first().map(|row| row.height).unwrap_or(0.0); + let rows_uniform = self + .rows + .iter() + .all(|row| (row.height - uniform_row_height).abs() <= 1.0e-9); + let uniform_number = |label: &str, + field: &'static str, + value: f64, + uniform: bool| + -> Property { + if uniform { + edit(label, field, value) + } else { + Property { + label: label.into(), + field, + value: PropValue::PlainText(t!("Varies").into_owned()), + } + } + }; + let override_count = self + .rows + .iter() + .filter(|row| row.style.is_some()) + .count() + + self + .columns + .iter() + .filter(|column| column.style.is_some()) + .count() + + self + .rows + .iter() + .flat_map(|row| row.cells.iter()) + .filter(|cell| cell.style.is_some()) + .count() + + usize::from(self.base_style.is_some() || self.legacy_style_override.is_some()); - vec![ + let mut sections = vec![ PropSection { title: t!("Table").into_owned(), props: vec![ - ro( - t!("Table style").as_ref(), - "tbl_style_handle", - "Standard", + ro(t!("Table style").as_ref(), "tbl_style_handle", "Standard"), + toggle( + t!("Title suppressed").as_ref(), + "tbl_title_suppressed", + title_suppressed, + true, + ), + toggle( + t!("Header suppressed").as_ref(), + "tbl_header_suppressed", + header_suppressed, + true, + ), + choice( + t!("Flow direction").as_ref(), + "tbl_flow_direction", + if table_flow_up { "Up" } else { "Down" }.to_string(), + vec!["Down".into(), "Up".into()], + true, ), - edit(t!("Insertion X").as_ref(), "tbl_insert_x", self.insertion_point.x), - edit(t!("Insertion Y").as_ref(), "tbl_insert_y", self.insertion_point.y), - edit(t!("Insertion Z").as_ref(), "tbl_insert_z", self.insertion_point.z), edit(t!("Direction").as_ref(), "tbl_direction", direction_deg), edit(t!("Rows").as_ref(), "tbl_rows", self.rows.len() as f64), edit(t!("Columns").as_ref(), "tbl_cols", self.columns.len() as f64), + uniform_number( + t!("Column width").as_ref(), + "tbl_column_width", + uniform_column_width, + columns_uniform, + ), + uniform_number( + t!("Row height").as_ref(), + "tbl_row_height", + uniform_row_height, + rows_uniform, + ), edit(t!("Table width").as_ref(), "tbl_width", self.total_width()), edit(t!("Table height").as_ref(), "tbl_height", self.total_height()), + edit( + t!("Horizontal cell margin").as_ref(), + "tbl_horizontal_margin", + horizontal_margin, + ), + edit( + t!("Vertical cell margin").as_ref(), + "tbl_vertical_margin", + vertical_margin, + ), + ro( + t!("Table overrides").as_ref(), + "tbl_overrides", + if override_count == 0 { + t!("None").into_owned() + } else { + override_count.to_string() + }, + ), + ], + }, + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + edit(t!("Insertion X").as_ref(), "tbl_insert_x", self.insertion_point.x), + edit(t!("Insertion Y").as_ref(), "tbl_insert_y", self.insertion_point.y), + edit(t!("Insertion Z").as_ref(), "tbl_insert_z", self.insertion_point.z), ro(t!("Normal X").as_ref(), "tbl_normal_x", format!("{:.4}", self.normal.x)), ro(t!("Normal Y").as_ref(), "tbl_normal_y", format!("{:.4}", self.normal.y)), ro(t!("Normal Z").as_ref(), "tbl_normal_z", format!("{:.4}", self.normal.z)), ], }, - PropSection { + ]; + + if prop_current_cell_active() { + let cell_type = match cell.map(|cell| cell.cell_type) { + Some(acadrust::entities::table::CellType::Block) => "Block", + _ => "Text", + }; + let data_type = cell + .and_then(|cell| cell.contents.first()) + .map(|content| match content.value.value_type { + acadrust::entities::table::CellValueType::Long => "Integer", + acadrust::entities::table::CellValueType::Double => "Decimal", + acadrust::entities::table::CellValueType::Date => "Date", + acadrust::entities::table::CellValueType::Point2D => "Point 2D", + acadrust::entities::table::CellValueType::Point3D => "Point 3D", + acadrust::entities::table::CellValueType::Handle => "Handle", + _ => "Text", + }) + .unwrap_or("Text"); + let style_source = if cell.and_then(|cell| cell.style.as_ref()).is_some() { + "Cell override" + } else if row_style.is_some() { + "Row override" + } else if column_style.is_some() { + "Column override" + } else { + "Table style" + }; + let text_style = style_for(CellStylePropertyFlags::TEXT_STYLE) + .map(|style| style.text_style_name.clone()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| "Standard".to_string()); + let mut available_text_styles = text_style_names.to_vec(); + if !available_text_styles + .iter() + .any(|name| name.eq_ignore_ascii_case(&text_style)) + { + available_text_styles.insert(0, text_style.clone()); + } + let rotation = style_for(CellStylePropertyFlags::ROTATION) + .map(|style| style.rotation) + .or_else(|| cell.map(|cell| cell.rotation)) + .unwrap_or(0.0) + .to_degrees(); + sections.push(PropSection { title: t!("Cell").into_owned(), props: vec![ Property { @@ -2027,144 +2522,291 @@ impl PropertyEditable for Table { "tbl_cell_column", cell_column.to_string(), ), - Property { - label: t!("Contents").into_owned(), - field: "tbl_cell_text", - value: PropValue::PlainText( - cell.map(|cell| cell.text_value().to_string()).unwrap_or_default(), - ), - }, - Property { - label: t!("Alignment").into_owned(), - field: "tbl_cell_alignment", - value: PropValue::Choice { - selected: alignment.to_string(), - options: vec![ - "Top Left".into(), - "Top Center".into(), - "Top Right".into(), - "Middle Left".into(), - "Middle Center".into(), - "Middle Right".into(), - "Bottom Left".into(), - "Bottom Center".into(), - "Bottom Right".into(), - ], - }, - }, - edit( + ro(t!("Cell style").as_ref(), "tbl_cell_style", style_source), + choice( + t!("Cell type").as_ref(), + "tbl_cell_type", + cell_type.to_string(), + vec!["Text".into(), "Block".into()], + content_editable, + ), + text( + t!("Contents").as_ref(), + "tbl_cell_text", + cell.map(|cell| cell.text_value().to_string()).unwrap_or_default(), + content_editable, + ), + choice( + t!("Alignment").as_ref(), + "tbl_cell_alignment", + alignment.to_string(), + vec![ + "Top Left".into(), + "Top Center".into(), + "Top Right".into(), + "Middle Left".into(), + "Middle Center".into(), + "Middle Right".into(), + "Bottom Left".into(), + "Bottom Center".into(), + "Bottom Right".into(), + ], + format_editable, + ), + choice( + t!("Text style").as_ref(), + "tbl_cell_text_style", + text_style, + available_text_styles, + format_editable, + ), + choice( + t!("Text rotation").as_ref(), + "tbl_cell_rotation", + format!("{rotation:.0}"), + vec!["0".into(), "90".into(), "180".into(), "270".into()], + format_editable, + ), + number( t!("Text height").as_ref(), "tbl_cell_text_height", - cell_style.map(|style| style.text_height).unwrap_or(0.18), + style_for(CellStylePropertyFlags::TEXT_HEIGHT) + .map(|style| style.text_height) + .unwrap_or(0.18), + format_editable, + ), + color( + t!("Content color").as_ref(), + "tbl_cell_content_color", + style_for(CellStylePropertyFlags::CONTENT_COLOR) + .map(|style| style.content_color) + .unwrap_or(acadrust::types::Color::ByBlock), + format_editable, + ), + color( + t!("Background color").as_ref(), + "tbl_cell_background_color", + style_for(CellStylePropertyFlags::BACKGROUND_COLOR) + .map(|style| style.background_color) + .unwrap_or(acadrust::types::Color::ByBlock), + format_editable, + ), + choice( + t!("Data type").as_ref(), + "tbl_cell_data_type", + data_type.to_string(), + vec![ + "Text".into(), + "Integer".into(), + "Decimal".into(), + "Date".into(), + "Point 2D".into(), + "Point 3D".into(), + "Handle".into(), + ], + format_editable, + ), + text( + t!("Data format").as_ref(), + "tbl_cell_format", + style_for(CellStylePropertyFlags::DATA_FORMAT) + .map(|style| style.value_format.clone()) + .unwrap_or_default(), + format_editable, ), - Property { - label: t!("Content color").into_owned(), - field: "tbl_cell_content_color", - value: PropValue::ColorChoice( - cell_style - .map(|style| style.content_color) - .unwrap_or(acadrust::types::Color::ByBlock), - ), - }, - Property { - label: t!("Background color").into_owned(), - field: "tbl_cell_background_color", - value: PropValue::ColorChoice( - cell_style - .map(|style| style.background_color) - .unwrap_or(acadrust::types::Color::ByBlock), - ), - }, - Property { - label: t!("Data format").into_owned(), - field: "tbl_cell_format", - value: PropValue::PlainText( - cell_style.map(|style| style.value_format.clone()).unwrap_or_default(), - ), - }, toggle( t!("Background fill").as_ref(), "tbl_cell_fill", - cell_style.is_some_and(|style| style.fill_enabled), + style_for(CellStylePropertyFlags::BACKGROUND_COLOR) + .is_some_and(|style| style.fill_enabled), + format_editable, + ), + number( + t!("Left margin").as_ref(), + "tbl_cell_margin_left", + style_for(CellStylePropertyFlags::MARGIN_LEFT) + .map(|style| style.margin_left) + .unwrap_or(horizontal_margin), + format_editable, + ), + number( + t!("Top margin").as_ref(), + "tbl_cell_margin_top", + style_for(CellStylePropertyFlags::MARGIN_TOP) + .map(|style| style.margin_top) + .unwrap_or(vertical_margin), + format_editable, + ), + number( + t!("Right margin").as_ref(), + "tbl_cell_margin_right", + style_for(CellStylePropertyFlags::MARGIN_RIGHT) + .map(|style| style.margin_right) + .unwrap_or(horizontal_margin), + format_editable, + ), + number( + t!("Bottom margin").as_ref(), + "tbl_cell_margin_bottom", + style_for(CellStylePropertyFlags::MARGIN_BOTTOM) + .map(|style| style.margin_bottom) + .unwrap_or(vertical_margin), + format_editable, ), toggle( t!("Top border").as_ref(), "tbl_cell_border_top", - cell_style.is_none_or(|style| !style.top_border.invisible), + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::TOP, + ) + }) + }) + .is_none_or(|style| !style.top_border.invisible), + format_editable, ), toggle( t!("Right border").as_ref(), "tbl_cell_border_right", - cell_style.is_none_or(|style| !style.right_border.invisible), + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::RIGHT, + ) + }) + }) + .is_none_or(|style| !style.right_border.invisible), + format_editable, ), toggle( t!("Bottom border").as_ref(), "tbl_cell_border_bottom", - cell_style.is_none_or(|style| !style.bottom_border.invisible), + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::BOTTOM, + ) + }) + }) + .is_none_or(|style| !style.bottom_border.invisible), + format_editable, ), toggle( t!("Left border").as_ref(), "tbl_cell_border_left", - cell_style.is_none_or(|style| !style.left_border.invisible), + table_row + .and_then(|row| { + cell.and_then(|cell| { + style_for_border( + self, + row, + cell_column, + cell, + acadrust::entities::table::CellEdgeFlags::LEFT, + ) + }) + }) + .is_none_or(|style| !style.left_border.invisible), + format_editable, + ), + toggle( + t!("Locked").as_ref(), + "tbl_cell_locked", + cell_locked, + !immutable_lock, ), - toggle(t!("Locked").as_ref(), "tbl_cell_locked", cell_locked), ], - }, + }); + } + + sections.push( PropSection { title: t!("Table Breaks").into_owned(), props: vec![ toggle( t!("Enabled").as_ref(), "tbl_break_enabled", - self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS), + breaks_enabled, + true, + ), + choice( + t!("Direction").as_ref(), + "tbl_break_direction", + match self.break_flow_direction { + acadrust::entities::table::BreakFlowDirection::Right => "Right", + acadrust::entities::table::BreakFlowDirection::Left => "Left", + acadrust::entities::table::BreakFlowDirection::Vertical => "Down", + } + .to_string(), + vec!["Right".into(), "Left".into(), "Down".into()], + breaks_enabled && !manual_positions, ), - Property { - label: t!("Direction").into_owned(), - field: "tbl_break_direction", - value: PropValue::Choice { - selected: match self.break_flow_direction { - acadrust::entities::table::BreakFlowDirection::Right => "Right", - acadrust::entities::table::BreakFlowDirection::Left => "Left", - acadrust::entities::table::BreakFlowDirection::Vertical => "Down", - } - .to_string(), - options: vec!["Right".into(), "Left".into(), "Down".into()], - }, - }, toggle( t!("Repeat top labels").as_ref(), "tbl_break_repeat_top", self.break_options .contains(BreakOptionFlags::REPEAT_TOP_LABELS), + breaks_enabled, ), toggle( t!("Repeat bottom labels").as_ref(), "tbl_break_repeat_bottom", self.break_options .contains(BreakOptionFlags::REPEAT_BOTTOM_LABELS), + breaks_enabled, ), toggle( t!("Manual positions").as_ref(), "tbl_break_manual_positions", - self.break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS), + manual_positions, + breaks_enabled, ), toggle( t!("Manual heights").as_ref(), "tbl_break_manual_heights", - self.break_options - .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS), + manual_heights, + breaks_enabled, + ), + number( + t!("Maximum height").as_ref(), + "tbl_break_height", + break_height, + breaks_enabled && !manual_heights, + ), + number( + t!("Spacing").as_ref(), + "tbl_break_spacing", + self.break_spacing, + breaks_enabled && !manual_positions, ), - edit(t!("Maximum height").as_ref(), "tbl_break_height", break_height), - edit(t!("Spacing").as_ref(), "tbl_break_spacing", self.break_spacing), ], }, - ] + ); + + sections } fn apply_geom_prop(&mut self, field: &str, value: &str) { use crate::entities::common::parse_f64; - use acadrust::entities::table::BreakOptionFlags; + use acadrust::entities::table::{ + BreakOptionFlags, CellStateFlags, CellStyle, CellStylePropertyFlags, + }; let flag = match field { "tbl_break_enabled" => Some(BreakOptionFlags::ENABLE_BREAKS), "tbl_break_repeat_top" => Some(BreakOptionFlags::REPEAT_TOP_LABELS), @@ -2174,32 +2816,104 @@ impl PropertyEditable for Table { _ => None, }; if let Some(flag) = flag { + if flag != BreakOptionFlags::ENABLE_BREAKS + && !self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + { + return; + } let on = if value == "toggle" { !self.break_options.contains(flag) } else { value == "true" }; self.break_options.set(flag, on); + self.break_ranges.clear(); return; } + match field { + "tbl_title_suppressed" | "tbl_header_suppressed" => { + let enabled = if value == "toggle" { + let current = if field == "tbl_title_suppressed" { + self.legacy_style_override + .as_ref() + .and_then(|style| style.title_suppressed) + .unwrap_or(false) + } else { + self.legacy_style_override + .as_ref() + .and_then(|style| style.header_suppressed) + .unwrap_or(false) + }; + !current + } else { + value == "true" + }; + let style = self.legacy_style_override.get_or_insert_with(Default::default); + if field == "tbl_title_suppressed" { + style.flags |= 0x0001; + style.title_suppressed = Some(enabled); + } else { + style.flags |= 0x0002; + style.header_suppressed = Some(enabled); + } + self.override_flag = true; + return; + } + "tbl_flow_direction" => { + let up = value.trim().eq_ignore_ascii_case("up"); + let style = self.base_style.get_or_insert_with(Default::default); + style + .property_flags + .set(CellStylePropertyFlags::FLOW_DIRECTION_BOTTOM_TO_TOP, up); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0004; + legacy.flow_direction = Some(if up { 1 } else { 0 }); + self.override_flag = true; + return; + } + _ => {} + } let cell_index = prop_current_cell(); let columns = self.columns.len(); let cell_position = (columns > 0).then_some((cell_index / columns, cell_index % columns)); if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + let content_editable = !state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::CONTENT_READ_ONLY, + ); + let format_editable = !state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ); match field { "tbl_cell_text" => { + if !content_editable { + return; + } if let Some(cell) = self.cell_mut(row, column) { - use acadrust::entities::table::CellStateFlags; - if !cell.state.intersects( - CellStateFlags::CONTENT_LOCKED - | CellStateFlags::CONTENT_READ_ONLY, - ) { - cell.set_text(value); - } + cell.set_text(value); + } + return; + } + "tbl_cell_type" => { + if !content_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + cell.cell_type = if value.trim().eq_ignore_ascii_case("block") { + acadrust::entities::table::CellType::Block + } else { + acadrust::entities::table::CellType::Text + }; } return; } "tbl_cell_alignment" => { + if !format_editable { + return; + } let alignment = match value.trim().to_ascii_uppercase().as_str() { "TOP LEFT" => 1, "TOP CENTER" => 2, @@ -2212,7 +2926,7 @@ impl PropertyEditable for Table { _ => 5, }; if let Some(cell) = self.cell_mut(row, column) { - let style = cell.style.get_or_insert_with(Default::default); + let style = cell.style.get_or_insert_with(CellStyle::new); style.alignment = alignment; style.property_flags.insert( acadrust::entities::table::CellStylePropertyFlags::ALIGNMENT, @@ -2220,9 +2934,67 @@ impl PropertyEditable for Table { } return; } - "tbl_cell_format" => { + "tbl_cell_text_style" => { + if !format_editable { + return; + } if let Some(cell) = self.cell_mut(row, column) { - let style = cell.style.get_or_insert_with(Default::default); + let style = cell.style.get_or_insert_with(CellStyle::new); + style.text_style_name = value.trim().to_string(); + style + .property_flags + .insert(CellStylePropertyFlags::TEXT_STYLE); + } + return; + } + "tbl_cell_rotation" => { + if !format_editable { + return; + } + if let Some(rotation) = parse_f64(value) { + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + style.rotation = rotation.to_radians(); + style + .property_flags + .insert(CellStylePropertyFlags::ROTATION); + } + } + return; + } + "tbl_cell_data_type" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + if cell.contents.is_empty() { + cell.set_text(""); + } + if let Some(content) = cell.contents.first_mut() { + content.value.value_type = match value.trim().to_ascii_uppercase().as_str() { + "INTEGER" => acadrust::entities::table::CellValueType::Long, + "DECIMAL" => acadrust::entities::table::CellValueType::Double, + "DATE" => acadrust::entities::table::CellValueType::Date, + "POINT 2D" => acadrust::entities::table::CellValueType::Point2D, + "POINT 3D" => acadrust::entities::table::CellValueType::Point3D, + "HANDLE" => acadrust::entities::table::CellValueType::Handle, + _ => acadrust::entities::table::CellValueType::String, + }; + content.value.raw_type_code = content.value.value_type as i32; + } + let style = cell.style.get_or_insert_with(CellStyle::new); + style + .property_flags + .insert(CellStylePropertyFlags::DATA_TYPE); + } + return; + } + "tbl_cell_format" => { + if !format_editable { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); style.value_format = value.to_string(); style.property_flags.insert( acadrust::entities::table::CellStylePropertyFlags::DATA_FORMAT, @@ -2231,8 +3003,11 @@ impl PropertyEditable for Table { return; } "tbl_cell_fill" => { + if !format_editable { + return; + } if let Some(cell) = self.cell_mut(row, column) { - let style = cell.style.get_or_insert_with(Default::default); + let style = cell.style.get_or_insert_with(CellStyle::new); style.fill_enabled = if value == "toggle" { !style.fill_enabled } else { @@ -2248,11 +3023,14 @@ impl PropertyEditable for Table { | "tbl_cell_border_right" | "tbl_cell_border_bottom" | "tbl_cell_border_left" => { + if !format_editable { + return; + } if let Some(cell) = self.cell_mut(row, column) { use acadrust::entities::table::{ BorderPropertyFlags, CellEdgeFlags, }; - let style = cell.style.get_or_insert_with(Default::default); + let style = cell.style.get_or_insert_with(CellStyle::new); let (border, edge) = match field { "tbl_cell_border_top" => (&mut style.top_border, CellEdgeFlags::TOP), "tbl_cell_border_right" => { @@ -2275,10 +3053,16 @@ impl PropertyEditable for Table { return; } "tbl_cell_locked" => { + if state.intersects( + CellStateFlags::CONTENT_READ_ONLY | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } if let Some(cell) = self.cell_mut(row, column) { - use acadrust::entities::table::CellStateFlags; let locked = if value == "toggle" { - !cell.state.contains(CellStateFlags::CONTENT_LOCKED) + !cell.state.intersects( + CellStateFlags::CONTENT_LOCKED | CellStateFlags::FORMAT_LOCKED, + ) } else { value == "true" }; @@ -2292,6 +3076,13 @@ impl PropertyEditable for Table { } let Some(number) = parse_f64(value) else { if field == "tbl_break_direction" { + if !self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + || self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS) + { + return; + } self.break_flow_direction = match value.trim().to_ascii_uppercase().as_str() { "LEFT" => acadrust::entities::table::BreakFlowDirection::Left, "DOWN" | "VERTICAL" => { @@ -2299,6 +3090,7 @@ impl PropertyEditable for Table { } _ => acadrust::entities::table::BreakFlowDirection::Right, }; + self.break_ranges.clear(); } return; }; @@ -2320,6 +3112,7 @@ impl PropertyEditable for Table { while self.rows.len() > requested { self.remove_row(self.rows.len() - 1); } + self.break_ranges.clear(); } "tbl_cols" => { let requested = number.round().max(1.0) as usize; @@ -2330,6 +3123,19 @@ impl PropertyEditable for Table { while self.columns.len() > requested { self.remove_column(self.columns.len() - 1); } + self.break_ranges.clear(); + } + "tbl_column_width" if number > 0.0 => { + for column in &mut self.columns { + column.width = number; + } + self.break_ranges.clear(); + } + "tbl_row_height" if number > 0.0 => { + for row in &mut self.rows { + row.height = number; + } + self.break_ranges.clear(); } "tbl_width" if number > 0.0 => { let current = self.total_width(); @@ -2338,6 +3144,7 @@ impl PropertyEditable for Table { column.width *= number / current; } } + self.break_ranges.clear(); } "tbl_height" if number > 0.0 => { let current = self.total_height(); @@ -2346,11 +3153,45 @@ impl PropertyEditable for Table { row.height *= number / current; } } + self.break_ranges.clear(); + } + "tbl_horizontal_margin" if number >= 0.0 => { + let style = self.base_style.get_or_insert_with(Default::default); + style.margin_left = number; + style.margin_right = number; + style.property_flags.insert( + CellStylePropertyFlags::MARGIN_LEFT | CellStylePropertyFlags::MARGIN_RIGHT, + ); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0008; + legacy.horizontal_cell_margin = Some(number); + self.override_flag = true; + } + "tbl_vertical_margin" if number >= 0.0 => { + let style = self.base_style.get_or_insert_with(Default::default); + style.margin_top = number; + style.margin_bottom = number; + style.property_flags.insert( + CellStylePropertyFlags::MARGIN_TOP | CellStylePropertyFlags::MARGIN_BOTTOM, + ); + let legacy = self.legacy_style_override.get_or_insert_with(Default::default); + legacy.flags |= 0x0010; + legacy.vertical_cell_margin = Some(number); + self.override_flag = true; } "tbl_cell_text_height" if number > 0.0 => { if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + if state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } if let Some(cell) = self.cell_mut(row, column) { - let style = cell.style.get_or_insert_with(Default::default); + let style = cell.style.get_or_insert_with(CellStyle::new); style.text_height = number; style.property_flags.insert( acadrust::entities::table::CellStylePropertyFlags::TEXT_HEIGHT, @@ -2358,7 +3199,53 @@ impl PropertyEditable for Table { } } } - "tbl_break_height" if number >= 0.0 => { + "tbl_cell_margin_left" + | "tbl_cell_margin_top" + | "tbl_cell_margin_right" + | "tbl_cell_margin_bottom" + if number >= 0.0 => + { + if let Some((row, column)) = cell_position { + let state = self + .cell(row, column) + .map(|cell| cell.state) + .unwrap_or_default(); + if state.intersects( + CellStateFlags::FORMAT_LOCKED | CellStateFlags::FORMAT_READ_ONLY, + ) { + return; + } + if let Some(cell) = self.cell_mut(row, column) { + let style = cell.style.get_or_insert_with(CellStyle::new); + let flag = match field { + "tbl_cell_margin_left" => { + style.margin_left = number; + CellStylePropertyFlags::MARGIN_LEFT + } + "tbl_cell_margin_top" => { + style.margin_top = number; + CellStylePropertyFlags::MARGIN_TOP + } + "tbl_cell_margin_right" => { + style.margin_right = number; + CellStylePropertyFlags::MARGIN_RIGHT + } + _ => { + style.margin_bottom = number; + CellStylePropertyFlags::MARGIN_BOTTOM + } + }; + style.property_flags.insert(flag); + } + } + } + "tbl_break_height" + if number >= 0.0 + && self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + && !self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_HEIGHTS) => + { if self.break_data.is_empty() { self.break_data.push(acadrust::entities::table::TableBreakData { position: acadrust::types::Vector3::ZERO, @@ -2370,8 +3257,18 @@ impl PropertyEditable for Table { data.height = number; } } + self.break_ranges.clear(); + } + "tbl_break_spacing" + if number >= 0.0 + && self.break_options.contains(BreakOptionFlags::ENABLE_BREAKS) + && !self + .break_options + .contains(BreakOptionFlags::ALLOW_MANUAL_POSITIONS) => + { + self.break_spacing = number; + self.break_ranges.clear(); } - "tbl_break_spacing" if number >= 0.0 => self.break_spacing = number, _ => {} } } diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 1e9a1519..fd814fab 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -520,6 +520,7 @@ impl CadCommand for TableCellEditCommand { return Some(CmdResult::Cancel); } cell.set_text(text); + self.table.block_record_handle = None; Some(CmdResult::ReplaceMany( vec![(self.handle, vec![EntityType::Table(self.table.clone())])], Vec::new(), From 745bc9fba7a7a696807484524f8801e0255a0b50 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 24 Aug 2026 19:57:30 +0300 Subject: [PATCH 34/98] fix(i18n): correct German regressions --- locales/de-DE/opencadstudio.ftl | 57 +++++++++++++++++---------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index c805286f..78ed63e0 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -100,7 +100,7 @@ modal = .layer-state-manager = Layerstatus-Verwaltung .edit-layer-state = Layerstatus bearbeiten .plot = Plotten - .layout-manager = Ansichtsverwaltung + .layout-manager = Layoutverwaltung .scale-manager = Maßstabsverwaltung .annotation-object-scale = Maßstab des Beschriftungsobjekts .plot-style-editor = Plotstileditor @@ -137,8 +137,8 @@ modal = .open-dwg-and-dxf-drawings-in-open-cad-studio-b = Öffnen Sie .dwg und .dxf Zeichnungen in Open CAD Studio standardmäßig. .path-path = Pfad: __ocs_arg_path__ .reload-from-disk = Von Festplatte neu laden - .save-as = Speichern unter als... - .saving-now-could-destroy-those-external-change = Diese Speichern kann externen Änderungen zerstören, laden Sie eine Festplattenkopie, speichern Sie Ihre Arbeit an anderer Stelle, oder überschreiben diese explizit. + .save-as = Speichern unter... + .saving-now-could-destroy-those-external-change = Wenn Sie jetzt speichern, könnten diese externen Änderungen verloren gehen. Laden Sie die Festplattenkopie neu, speichern Sie Ihre lokale Arbeit an anderer Stelle oder überschreiben Sie sie ausdrücklich. .the-file-is-open-or-being-used-by-another-appl = Die Datei ist geöffnet oder wird von einer anderen Anwendung verwendet. Schließen Sie die andere Anwendung und versuchen Sie es erneut oder speichern Sie diese Zeichnung unter einem anderen Namen. .the-selected-layer-state-is-no-longer-availabl = Der ausgewählte Ebenenzustand steht nicht mehr zur Verfügung. .this-drawing-contains-count-aec-civil-objects = @@ -314,6 +314,7 @@ common = .block-rotation = Blockdrehung .blocks = Blöcke .blue = Blau + .bulge = Wölbung .boolean-failed-the-solids-may-not-overlap = Boolean versagte - die Volumenkörper überlappen sich möglicherweise nicht. .boolean-select-exactly-two-solids-created-this = Boolean: Wählen Sie genau zwei erstellte Volumenkörper. .bottom = Unten @@ -340,9 +341,9 @@ common = .cancel = Abbrechen .cancel-active-command = Aktiven Befehl abbrechen .cannot-delete-a-style-that-is-current-or-in-us = Der Stil ist aktuell in Verwendung und kann nicht gelöscht werden. - .cannot-delete-the-model-layout = Die Modellansicht kann nicht gelöscht werden. + .cannot-delete-the-model-layout = Das Modell-Layout kann nicht gelöscht werden. .cannot-delete-the-standard-style = Der Standardstil kann nicht gelöscht werden. - .cannot-rename-the-model-layout = Die Modellansicht kann nicht umbenannt werden. + .cannot-rename-the-model-layout = Das Modell-Layout kann nicht umbenannt werden. .cannot-rename-the-standard-style = Der Standard-Stil kann nicht umbenannt werden. .catchment-tagged-successfully = Catchment erfolgreich verschlagwortet. .center = Mitte @@ -554,15 +555,15 @@ common = .layer-state-name-was-not-found = Ebenenzustand "__ocs_fmt_0__" wurde nicht gefunden. .layer-state-name-cannot-be-empty = Der Name des Ebenen-Status kann nicht leer sein. .layers = Ebenen - .layout = Ansicht - .layout-name-silindi = Ansicht „__ocs_fmt_0__“ gelöscht - .layout-new-name-created-use-mview-to-add-a-vie = Ansicht „__ocs_fmt_0__“ erstellt — mit MVIEW kann ein Ansichtsfenster hinzugefügt werden - .layout-orig-new-name = Ansicht „__ocs_fmt_0__" → "__ocs_fmt_1__" - .layout-name-created = Ansicht '__ocs_fmt_0__' erstellt. - .layout-name-deleted = Ansicht '__ocs_fmt_0__' gelöscht. - .layout-manager = Ansichtsverwaltung - .layout-name-cannot-be-empty = Der Ansichtsname kann nicht leer sein. - .layout-renamed-old-name-new-name = Ansicht umbenannt: '__ocs_fmt_0__' → '__ocs_fmt_1__' + .layout = Layout + .layout-name-silindi = Layout „__ocs_fmt_0__“ gelöscht + .layout-new-name-created-use-mview-to-add-a-vie = Layout „__ocs_fmt_0__“ erstellt — mit MVIEW kann ein Ansichtsfenster hinzugefügt werden + .layout-orig-new-name = Layout „__ocs_fmt_0__“ → „__ocs_fmt_1__“ + .layout-name-created = Layout „__ocs_fmt_0__“ erstellt. + .layout-name-deleted = Layout „__ocs_fmt_0__“ gelöscht. + .layout-manager = Layoutverwaltung + .layout-name-cannot-be-empty = Der Layoutname kann nicht leer sein. + .layout-renamed-old-name-new-name = Layout umbenannt: „__ocs_fmt_0__“ → „__ocs_fmt_1__“ .leader = Führungslinie .leader-format = Führungslinienformat .leader-structure = Struktur der Führungslinie @@ -591,7 +592,7 @@ common = .mleaderalign-leaders-aligned = MLEADERALIGN Führungslinien ausgerichtet. .mleadercollect-leaders-collected = MLEADERCOLLECT Führungslinien gesammelt. .model-uppercase = MODEL - .ms-is-only-available-in-paper-space-layouts = MS ist nur in Papieransicht verfügbar. + .ms-is-only-available-in-paper-space-layouts = MS ist nur in Papierraum-Layouts verfügbar. .mspace = MSPACE .mtext = Mtext .mview-the-clipping-boundary-has-no-usable-area = MVIEW: Die Schnittgrenze hat keine nutzbare Fläche. @@ -623,7 +624,7 @@ common = .no-drawing-open-use-new-or-open-first = Keine Zeichnung geöffnet – zuerst Neu oder Öffnen verwenden. .no-groups-found-for-selected-objects = Keine Gruppen für ausgewählte Objekte gefunden. .no-plugin-handled-command = Kein Plugin bearbeitet: __ocs_fmt_0__ - .no-viewport-found-in-this-layout = In dieser Ansicht wurde kein Ansichtsfenster gefunden. + .no-viewport-found-in-this-layout = In diesem Layout wurde kein Ansichtsfenster gefunden. .node = Knotenpunkt .none-title-case = Keine .normal = Normal @@ -643,10 +644,10 @@ common = .open-dwg-dxf-not-yet-implemented = DWG/DXF öffnen: noch nicht implementiert. .open-cancelled = Geöffnet abgesagt: "__ocs_fmt_0__" .open-failed-e = Öffnen fehlgeschlagen: __ocs_fmt_0__ - .open-or-create-a-drawing-to-add-a-layout = Öffnen oder erstellen Sie eine Zeichnung, um ein Ansicht hinzuzufügen. + .open-or-create-a-drawing-to-add-a-layout = Öffnen oder erstellen Sie eine Zeichnung, um ein Layout hinzuzufügen. .open-or-create-a-drawing-to-manage-layer-state = Öffnen oder erstellen Sie eine Zeichnung, um Ebenenzustände zu verwalten. - .open-or-create-a-drawing-to-manage-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Ansichten zu verwalten. - .open-or-create-a-drawing-to-switch-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Ansichten zu wechseln. + .open-or-create-a-drawing-to-manage-layouts = Öffnen oder erstellen Sie eine Zeichnung, um Layouts zu verwalten. + .open-or-create-a-drawing-to-switch-layouts = Öffnen oder erstellen Sie eine Zeichnung, um zwischen Layouts zu wechseln. .opened-file-location = Geöffneter Dateispeicherort: __ocs_fmt_0__ .opening-name-size-label = Öffnen "__ocs_fmt_0__" (__ocs_fmt_1__)... .options-title-case = Optionen @@ -823,7 +824,7 @@ common = .suppress-outside-arrows-dimsoxd = Unterdrücken Sie äußere Pfeile (DIMSOXD) .suppress-trailing = Nachlauf unterdrücken .suppress-trailing-zeros = Unterdrücken Sie nachlaufende Nullen - .switched-to-layout-name = Umgeschalten auf Ansicht '__ocs_fmt_0__'. + .switched-to-layout-name = Zu Layout „__ocs_fmt_0__“ gewechselt. .textedit-selected-entity-is-not-text = TEXTEDIT: Das ausgewählte Objekt ist kein Text. .texteditmode-set-to-display-val = TEXTEDITMODE auf __ocs_fmt_0__ gesetzt .table = Tabelle @@ -949,10 +950,10 @@ common = .zoom-window = Ausdehnungsfenster .command-lowercase = Befehl .e-g-arial = z. B. Arial - .linetype-lowercase = Kleinschreibung Typ - .lineweight-lowercase = Kleinschreibung Gewicht - .object-lowercase = Kleinschreibung Objekt - .options-lowercase = Kleinschreibung Optionen + .linetype-lowercase = Linientyp + .lineweight-lowercase = Liniengewicht + .object-lowercase = Objekt + .options-lowercase = Optionen .units = Einheiten .count-object-s-added-to-selection = __ocs_fmt_0__ Objekt(e) zur Auswahl hinzugefügt. .count-object-s-pasted = __ocs_fmt_0__ Objekt(e) eingefügt. @@ -1246,8 +1247,8 @@ draw = .circle-ttt-select-second-tangent-object = CIRCLE TTT Wähle das zweite Tangentenobjekt aus: .circle-ttt-select-third-tangent-object = CIRCLE TTT Wähle drittes Tangentenobjekt: .copy-specify-base-point-count-objects = COPY Basispunkt angeben [__ocs_arg_count__ Objekte]: - .center-line = Zentrum, Linie - .center-mark = Zentrum, Marker + .center-line = Mittellinie + .center-mark = Mittelpunktmarkierung .center-axes = Zentrum, Achsen .center-diameter = Mitte, Durchmesser .center-radius = Mitte, Radius @@ -1322,9 +1323,9 @@ draw = .polysolid-wall-width = POLYSOLID Wandbreite: .pyramid-base-radius = PYRAMID Grundradius: .pyramid-height-add-sides-by-typing-a-3rd-numbe = PYRAMID Höhe (Seiten hinzufügen durch Eingabe einer 3. Zahl): - .polygon-circumscribed = Polygon – Umschreibung + .polygon-circumscribed = Polygon – umschrieben .polygon-edge = Polygon - Rand - .polygon-inscribed = Polygon - Vieleck + .polygon-inscribed = Polygon – eingeschrieben .ray-specify-start-point = RAY Startpunkt angeben: .ray-specify-through-point = RAY Geben Sie durch Punkt an: .rect-specify-first-corner = RECT Geben Sie die erste Ecke an: From 3d359196566d5c6c145ad4bc08cb17339e7180f2 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:01:06 +0200 Subject: [PATCH 35/98] feat(plugin): gate marketplace and load on acadrust source - Add host_acadrust_source and source-hash comparison helpers to ocs_plugin_api. - Track acadrust_source and acadrust_declared in ExternalPlugin/ReleaseInfo. - Apply repo-level policy: if any release in a repo declares acadrust_source, undeclared v4+ releases from that repo are incompatible. - Preserve API v2/v3 backwards compatibility: legacy undeclared releases stay installable. - Update Plugin Manager UI to only offer compatible releases and show 'acadrust mismatch' status. - Drop acadrust_version from gate logic; only acadrust_source is required. --- crates/ocs_plugin_api/src/version_info.rs | 104 ++++++++++++++- src/plugin/external.rs | 153 +++++++++++++++++++++- src/plugin/marketplace.rs | 63 ++++++++- src/ui/window/plugin_manager.rs | 103 ++++++++++----- 4 files changed, 384 insertions(+), 39 deletions(-) diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 1d690c07..e740af75 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -1,5 +1,7 @@ //! Embedded version metadata generated at build time. +use std::sync::OnceLock; + /// The embedded version info as a JSON string. pub const EMBEDDED_VERSION_INFO_JSON: &str = include_str!(concat!(env!("OUT_DIR"), "/version_info.json")); @@ -9,6 +11,60 @@ pub fn get_embedded_version_info_json() -> &'static str { EMBEDDED_VERSION_INFO_JSON } +#[derive(Debug, Clone)] +struct VersionInfo { + acadrust_source: String, +} + +/// Extract the string value for a top-level JSON key from a compact JSON +/// object. Only handles string values and does not unescape. +fn json_string_value(json: &str, key: &str) -> Option { + let pattern = format!(r#""{key}":""#); + let start = json.find(&pattern)? + pattern.len(); + let end = json[start..].find('"')?; + Some(json[start..start + end].to_string()) +} + +fn parsed_version_info() -> &'static VersionInfo { + static INFO: OnceLock = OnceLock::new(); + INFO.get_or_init(|| VersionInfo { + acadrust_source: json_string_value(EMBEDDED_VERSION_INFO_JSON, "acadrust_source") + .unwrap_or_default(), + }) +} + +/// The full `acadrust` git source this host was built against, in Cargo source +/// form (e.g. `git+https://github.com/...?rev=#`). +pub fn host_acadrust_source() -> &'static str { + &parsed_version_info().acadrust_source +} + +/// Extract the canonical 40-character lowercase git commit hash from an +/// `acadrust_source` string. +/// +/// Cargo git sources end in `#` regardless of whether the `rev` +/// query parameter was a short hash, tag, or branch. Returns `None` if no +/// 40-character hex suffix is present. +pub fn acadrust_source_hash(source: &str) -> Option<&str> { + let hash = source.rsplit('#').next()?; + if hash.len() == 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) { + Some(hash) + } else { + None + } +} + +/// True when two `acadrust_source` strings refer to the same full commit. +/// +/// Missing or unparseable sources are treated as incompatible (this helper is +/// intended for callers that have already decided a fingerprint is present). +pub fn acadrust_sources_compatible(a: &str, b: &str) -> bool { + match (acadrust_source_hash(a), acadrust_source_hash(b)) { + (Some(ha), Some(hb)) => ha.eq_ignore_ascii_case(hb), + _ => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -19,7 +75,7 @@ mod tests { assert!(!json.is_empty()); let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON"); assert!(value.get("ocs_version").is_some()); - assert!(value.get("acadrust_version").is_some()); + assert!(value.get("acadrust_source").is_some()); } #[test] @@ -54,4 +110,50 @@ mod tests { let acadrust = value["acadrust_version"].as_str().expect("acadrust_version string"); assert_eq!(acadrust.split('.').count(), 3); } + + #[test] + fn host_acadrust_source_is_non_empty() { + assert!(!host_acadrust_source().is_empty()); + } + + #[test] + fn extracts_full_hash_from_cargo_source() { + let src = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + assert_eq!( + acadrust_source_hash(src), + Some("94df2c3f87fa051b16ffc3923f80e9247c85c5fd") + ); + } + + #[test] + fn rejects_malformed_or_missing_hash() { + assert!(acadrust_source_hash("").is_none()); + assert!(acadrust_source_hash("registry+https://crates.io").is_none()); + assert!(acadrust_source_hash("git+https://github.com/foo/bar.git#short").is_none()); + assert!( + acadrust_source_hash("git+https://github.com/foo/bar.git#gggggggggggggggggggggggggggggggggggggggg") + .is_none() + ); + } + + #[test] + fn source_comparison_matches_full_hashes() { + let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + assert!(acadrust_sources_compatible(a, b)); + } + + #[test] + fn source_comparison_detects_mismatch() { + let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb"; + assert!(!acadrust_sources_compatible(a, b)); + } + + #[test] + fn source_comparison_case_insensitive() { + let a = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd"; + let b = "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94DF2C3F87FA051B16FFC3923F80E9247C85C5FD"; + assert!(acadrust_sources_compatible(a, b)); + } } diff --git a/src/plugin/external.rs b/src/plugin/external.rs index d7df8c48..33924d8c 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -30,6 +30,13 @@ pub struct RegistryEntry { pub struct ReleaseInfo { pub tag: String, pub api_version: u32, + /// Full `acadrust` git source the release was built against. + pub acadrust_source: Option, + /// Whether the manifest explicitly declares `acadrust_source` under + /// `[opencad]`. When false, the acadrust gate is skipped. + pub acadrust_declared: bool, + /// Whether the release's `acadrust_source` matches this host. + pub acadrust_compatible: bool, } /// An add-on package found on disk (not necessarily loaded or compatible). @@ -44,6 +51,12 @@ pub struct ExternalPlugin { /// an older `plugin.toml` does not declare `repository`. pub repository: Option, pub api_version: u32, + /// Full `acadrust` git source the plugin was built against. + pub acadrust_source: Option, + /// Whether the manifest explicitly declares `acadrust_source` under + /// `[opencad]`. When false, the acadrust gate is skipped and only the + /// API-version gate is applied (legacy plugins). + pub acadrust_declared: bool, pub ribbon_order: i32, pub command_prefixes: Vec, /// The package directory under the plugins folder. @@ -58,11 +71,28 @@ impl ExternalPlugin { ocs_plugin_api::manifest::host_accepts_plugin_version(self.api_version) } - /// True when the package can be loaded today: compatible API *and* a native - /// library present for this platform. + /// True when the package's `acadrust` fingerprint matches the host. + /// + /// If the manifest does not declare `acadrust_source`, the gate is skipped + /// (legacy fallback). If declared but empty or mismatched, incompatible. + pub fn acadrust_compatible(&self) -> bool { + if !self.acadrust_declared { + return true; + } + match self.acadrust_source.as_deref() { + None | Some("") => false, + Some(source) => ocs_plugin_api::version_info::acadrust_sources_compatible( + source, + ocs_plugin_api::version_info::host_acadrust_source(), + ), + } + } + + /// True when the package can be loaded today: compatible API *and* matching + /// `acadrust` fingerprint (if declared) *and* a native library present. #[allow(dead_code)] // plugin-host surface (issue #100); not yet wired pub fn loadable(&self) -> bool { - self.api_compatible() && self.lib_present + self.api_compatible() && self.acadrust_compatible() && self.lib_present } } @@ -176,6 +206,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { let mut description = String::new(); let mut repository = None; let mut api_version: u32 = 0; + let mut acadrust_source: Option = None; + let mut acadrust_declared = false; let mut ribbon_order: i32 = 0; let mut command_prefixes: Vec = Vec::new(); @@ -196,6 +228,11 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { "description" => description = unquote(value), "repository" => repository = normalize_repository(&unquote(value)), "api_version" => api_version = value.parse().unwrap_or(0), + "acadrust_source" => { + acadrust_declared = true; + let v = unquote(value); + acadrust_source = if v.is_empty() { None } else { Some(v) }; + } "ribbon_order" => ribbon_order = value.parse().unwrap_or(0), "command_prefixes" => command_prefixes = parse_string_array(value), _ => {} @@ -209,6 +246,8 @@ pub(crate) fn parse_plugin_toml(text: &str) -> Option { description, repository, api_version, + acadrust_source, + acadrust_declared, ribbon_order, command_prefixes, dir: PathBuf::new(), @@ -298,7 +337,33 @@ mod loader { manager.set_notification_handler(v4_support::notification_handler()); let mut out = Vec::new(); for d in &discovered { - if !d.api_compatible() || !d.lib_present { + if !d.api_compatible() { + continue; + } + if !d.lib_present { + continue; + } + if d.acadrust_declared && d.acadrust_source.is_none() { + eprintln!( + "[plugin] {} declares acadrust metadata but has no fingerprint; cannot verify compatibility", + d.id + ); + } + if !d.acadrust_compatible() { + let host_src = ocs_plugin_api::version_info::host_acadrust_source(); + let plugin_hash = d + .acadrust_source + .as_deref() + .and_then(ocs_plugin_api::version_info::acadrust_source_hash) + .unwrap_or("unknown"); + let host_hash = ocs_plugin_api::version_info::acadrust_source_hash(host_src) + .unwrap_or("unknown"); + out.push(( + d.id.clone(), + Err(format!( + "Plugin built for acadrust @{plugin_hash}, but this host uses @{host_hash}" + )), + )); continue; } let Some(path) = lib_file(&d.dir) else { @@ -412,6 +477,86 @@ xdata_apps = ["MYPLUGIN_RECORD"] assert!(!p.loadable()); } + #[test] + fn undeclared_acadrust_falls_back_to_api_gate() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert!(!p.acadrust_declared); + assert!(p.acadrust_source.is_none()); + assert!(p.acadrust_compatible(), "undeclared acadrust is treated as compatible"); + } + + #[test] + fn declared_empty_acadrust_source_is_incompatible() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +acadrust_source = "" +"#; + let p = parse_plugin_toml(toml).expect("parsed"); + assert!(p.acadrust_declared); + assert!(p.acadrust_source.is_none()); + assert!(!p.acadrust_compatible(), "declared but empty source is incompatible"); + assert!(!p.loadable()); + } + + #[test] + fn acadrust_mismatch_detected() { + let host = ocs_plugin_api::version_info::host_acadrust_source(); + let other = if host.contains("94df2c3") { + "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb" + } else { + "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd" + }; + let toml = format!( + r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +acadrust_source = "{other}" +"# + ); + let p = parse_plugin_toml(&toml).expect("parsed"); + assert!(p.acadrust_declared); + assert!(!p.acadrust_compatible(), "mismatched acadrust fingerprint should be incompatible"); + assert!(!p.loadable()); + } + + #[test] + fn acadrust_match_detected() { + let host = ocs_plugin_api::version_info::host_acadrust_source(); + let toml = format!( + r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 4 + +[opencad] +acadrust_source = "{host}" +"# + ); + let p = parse_plugin_toml(&toml).expect("parsed"); + assert!(p.acadrust_declared); + assert!(p.acadrust_compatible(), "matching acadrust fingerprint should be compatible"); + } + /// Integration smoke test for the out-of-process plugin path. /// Set `OCS_TEST_PLUGIN` to the built cdylib path and make sure the /// `OpenCADStudio` binary is built; the test uses it as the runner host. diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index 797580c4..7976cd46 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -219,9 +219,27 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { let manifest_text = download_string(&manifest_asset.url)?; let manifest = external::parse_plugin_toml(&manifest_text) .ok_or_else(|| format!("release {} plugin.toml is missing an id", release.tag))?; + let acadrust_source = manifest.acadrust_source.clone(); + let acadrust_declared = manifest.acadrust_declared; + let acadrust_compatible = if !acadrust_declared { + // Legacy release that predates acadrust metadata: defer the + // decision to the repo-level policy below. + true + } else { + match acadrust_source.as_deref() { + None | Some("") => false, + Some(source) => ocs_plugin_api::version_info::acadrust_sources_compatible( + source, + ocs_plugin_api::version_info::host_acadrust_source(), + ), + } + }; Ok::<_, String>(ReleaseInfo { tag: release.tag, api_version: manifest.api_version, + acadrust_source, + acadrust_declared, + acadrust_compatible, }) })(); match result { @@ -229,6 +247,21 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { Err(error) => last_error = Some(error), } } + + // Repo-level policy: if any release in this repo declares an acadrust + // fingerprint, treat undeclared releases from the same repo as + // incompatible — but only for releases targeting the current host API + // generation (v4 and above). This preserves API v2/v3 backwards + // compatibility for legacy plugins that predate acadrust metadata. + let any_declared = info.iter().any(|r| r.acadrust_declared); + if any_declared { + for r in &mut info { + if !r.acadrust_declared && r.api_version >= ocs_plugin_api::API_VERSION { + r.acadrust_compatible = false; + } + } + } + if info.is_empty() { Err(last_error.unwrap_or_else(|| "no installable releases found".to_string())) } else { @@ -283,8 +316,8 @@ fn download_bytes(url: &str) -> Result, String> { } /// Download and install a release's package into the plugins folder. Verifies -/// the API version from the package's `plugin.toml` first. Returns the plugin -/// id on success. +/// the API version and, when present, the `acadrust` fingerprint from the +/// package's `plugin.toml` first. Returns the plugin id on success. pub fn install(release: &Release, repository: &str) -> Result { let lib = release.lib_asset().ok_or("no library for this platform")?; let toml = release.toml_asset().ok_or("release has no plugin.toml")?; @@ -300,6 +333,32 @@ pub fn install(release: &Release, repository: &str) -> Result { )); } + if manifest.acadrust_declared { + let Some(source) = manifest.acadrust_source.as_deref() else { + return Err( + "Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(), + ); + }; + if source.is_empty() { + return Err( + "Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(), + ); + } + if !ocs_plugin_api::version_info::acadrust_sources_compatible( + source, + ocs_plugin_api::version_info::host_acadrust_source(), + ) { + let host_src = ocs_plugin_api::version_info::host_acadrust_source(); + let plugin_hash = ocs_plugin_api::version_info::acadrust_source_hash(source) + .unwrap_or("unknown"); + let host_hash = ocs_plugin_api::version_info::acadrust_source_hash(host_src) + .unwrap_or("unknown"); + return Err(format!( + "Plugin built for acadrust @{plugin_hash}, but this host uses @{host_hash}" + )); + } + } + let dir = external::plugins_dir() .ok_or("cannot locate the plugins folder")? .join(&manifest.id); diff --git a/src/ui/window/plugin_manager.rs b/src/ui/window/plugin_manager.rs index 6a212cc2..6b17838d 100644 --- a/src/ui/window/plugin_manager.rs +++ b/src/ui/window/plugin_manager.rs @@ -196,17 +196,22 @@ fn trim_version_prefix(value: &str) -> &str { value.trim_start_matches(|c| c == 'v' || c == 'V') } -fn newest_update(installed: &str, tags: &[String]) -> Option { +fn newest_update(installed: &str, releases: &[ReleaseInfo]) -> Option { let installed = semver::Version::parse(trim_version_prefix(installed)).ok()?; - tags.iter() - .filter_map(|tag| { - semver::Version::parse(trim_version_prefix(tag)) + releases + .iter() + .filter(|release| { + ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version) + && release.acadrust_compatible + }) + .filter_map(|release| { + semver::Version::parse(trim_version_prefix(&release.tag)) .ok() - .map(|version| (version, tag)) + .map(|version| (version, release.tag.clone())) }) .filter(|(version, _)| version > &installed) .max_by(|(left, _), (right, _)| left.cmp(right)) - .map(|(_, tag)| tag.clone()) + .map(|(_, tag)| tag) } fn external_card<'a>( @@ -219,12 +224,15 @@ fn external_card<'a>( selected: bool, ) -> Element<'a, Message> { let failed_old_api = load_error.is_some() && !p.api_compatible(); + let acadrust_mismatch = p.acadrust_declared && !p.acadrust_compatible(); let (status, kind) = if loaded && disabled { (t!("Disabled"), StatusKind::Muted) } else if loaded { (t!("Loaded"), StatusKind::Success) } else if !p.api_compatible() || failed_old_api { (t!("API incompatible"), StatusKind::Danger) + } else if acadrust_mismatch { + (t!("acadrust mismatch"), StatusKind::Danger) } else if load_error.is_some() { (t!("Load failed"), StatusKind::Danger) } else if !p.lib_present { @@ -352,11 +360,8 @@ fn install_controls<'a>( .iter() .map(|release| release.tag.clone()) .collect::>(); - let selected_api = selected.as_ref().and_then(|selected| { - releases - .iter() - .find(|release| release.tag == *selected) - .map(|release| release.api_version) + let selected_release = selected.as_ref().and_then(|selected| { + releases.iter().find(|release| release.tag == *selected) }); let picker: Element<'_, Message> = if tags.is_empty() { text(t!("no releases")).size(11).style(muted_style).into() @@ -367,9 +372,10 @@ fn install_controls<'a>( .text_size(12) .into() }; - let action = match selected_api { - Some(api_version) - if ocs_plugin_api::manifest::host_accepts_plugin_version(api_version) => + let action = match selected_release { + Some(release) + if ocs_plugin_api::manifest::host_accepts_plugin_version(release.api_version) + && release.acadrust_compatible => { pill_button( t!("Install"), @@ -842,18 +848,7 @@ pub fn view_window<'a>( let update_tag = repository .as_ref() .and_then(|repo| market.release_tags.get(repo)) - .and_then(|releases| { - let compatible_tags = releases - .iter() - .filter(|release| { - ocs_plugin_api::manifest::host_accepts_plugin_version( - release.api_version, - ) - }) - .map(|release| release.tag.clone()) - .collect::>(); - newest_update(&p.version, &compatible_tags) - }); + .and_then(|releases| newest_update(&p.version, releases)); list = list.push(external_card( p, repository, @@ -984,16 +979,60 @@ pub fn view_web_notice<'a>() -> Element<'a, Message> { #[cfg(test)] mod tests { use super::{newest_update, registry_error_message, repository_display_name}; + use crate::plugin::external::ReleaseInfo; #[test] fn newest_update_uses_semver_not_release_order() { - let tags = vec![ - "v1.4.0".to_string(), - "v2.0.0".to_string(), - "v1.9.9".to_string(), + let releases = vec![ + ReleaseInfo { + tag: "v1.4.0".to_string(), + api_version: 4, + acadrust_source: None, + acadrust_declared: false, + acadrust_compatible: true, + }, + ReleaseInfo { + tag: "v2.0.0".to_string(), + api_version: 4, + acadrust_source: None, + acadrust_declared: false, + acadrust_compatible: true, + }, + ReleaseInfo { + tag: "v1.9.9".to_string(), + api_version: 4, + acadrust_source: None, + acadrust_declared: false, + acadrust_compatible: true, + }, ]; - assert_eq!(newest_update("1.3.0", &tags).as_deref(), Some("v2.0.0")); - assert_eq!(newest_update("2.0.0", &tags), None); + assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v2.0.0")); + assert_eq!(newest_update("2.0.0", &releases), None); + } + + #[test] + fn newest_update_ignores_incompatible_releases() { + let releases = vec![ + ReleaseInfo { + tag: "v1.4.0".to_string(), + api_version: 4, + acadrust_source: Some( + "git+https://github.com/HakanSeven12/cadcodec.git?rev=94df2c3#94df2c3f87fa051b16ffc3923f80e9247c85c5fd".to_string(), + ), + acadrust_declared: true, + acadrust_compatible: true, + }, + ReleaseInfo { + tag: "v2.0.0".to_string(), + api_version: 4, + acadrust_source: Some( + "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb".to_string(), + ), + acadrust_declared: true, + acadrust_compatible: false, + }, + ]; + assert_eq!(newest_update("1.3.0", &releases).as_deref(), Some("v1.4.0")); } #[test] From 2e29f6c59ec3e38c72d9a2cf5efc7575b6cfaaa4 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:01:19 +0200 Subject: [PATCH 36/98] fix(tests): repair solid_model tri_count helper after upstream merge mesh_from_solid was removed in the upstream merge; use tessellation(body).mesh.triangles directly. --- src/scene/model/solid_model.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index b229981c..e2b8cedb 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -441,9 +441,7 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - mesh_from_solid(body, [0.7, 0.7, 0.7, 1.0]) - .map(|m| m.lods[0].indices.len() / 3) - .unwrap_or(0) + tessellation(body).mesh.triangles.len() } #[test] From c3581377920d8d3b59012957e8bd9e99b5713e1e Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:27:03 +0200 Subject: [PATCH 37/98] fix(plugin): restrict acadrust gate to API v4 and newer - API v2/v3 plugins bypass the acadrust gate entirely, preserving backwards compatibility. - Marketplace repo-level policy only affects undeclared v4+ releases. - Local load_at_startup only enforces the gate for v4+ plugins. --- src/plugin/external.rs | 35 +++++++++++++++++++++++++++++++---- src/plugin/marketplace.rs | 17 +++++++++-------- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/src/plugin/external.rs b/src/plugin/external.rs index 33924d8c..355aac93 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -73,9 +73,14 @@ impl ExternalPlugin { /// True when the package's `acadrust` fingerprint matches the host. /// - /// If the manifest does not declare `acadrust_source`, the gate is skipped - /// (legacy fallback). If declared but empty or mismatched, incompatible. + /// The acadrust gate only applies to plugins targeting API v4 or newer; + /// older API versions predate the gate and are treated as compatible. + /// If the manifest does not declare `acadrust_source`, the gate is skipped. + /// If declared but empty or mismatched, incompatible. pub fn acadrust_compatible(&self) -> bool { + if self.api_version < ocs_plugin_api::API_VERSION { + return true; + } if !self.acadrust_declared { return true; } @@ -343,13 +348,13 @@ mod loader { if !d.lib_present { continue; } - if d.acadrust_declared && d.acadrust_source.is_none() { + if d.api_version >= ocs_plugin_api::API_VERSION && d.acadrust_declared && d.acadrust_source.is_none() { eprintln!( "[plugin] {} declares acadrust metadata but has no fingerprint; cannot verify compatibility", d.id ); } - if !d.acadrust_compatible() { + if d.api_version >= ocs_plugin_api::API_VERSION && !d.acadrust_compatible() { let host_src = ocs_plugin_api::version_info::host_acadrust_source(); let plugin_hash = d .acadrust_source @@ -557,6 +562,28 @@ acadrust_source = "{host}" assert!(p.acadrust_compatible(), "matching acadrust fingerprint should be compatible"); } + #[test] + fn acadrust_gate_only_applies_to_api_v4_and_newer() { + let toml = r#" +[plugin] +id = "opencad.test" +name = "Test" +version = "0.1.0" +api_version = 2 + +[opencad] +acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb" +"#; + let mut p = parse_plugin_toml(toml).expect("parsed"); + p.lib_present = true; + assert!(p.acadrust_declared); + assert!( + p.acadrust_compatible(), + "API v2 plugin should bypass acadrust gate" + ); + assert!(p.loadable()); + } + /// Integration smoke test for the out-of-process plugin path. /// Set `OCS_TEST_PLUGIN` to the built cdylib path and make sure the /// `OpenCADStudio` binary is built; the test uses it as the runner host. diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index 7976cd46..b6e5069a 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -221,9 +221,11 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { .ok_or_else(|| format!("release {} plugin.toml is missing an id", release.tag))?; let acadrust_source = manifest.acadrust_source.clone(); let acadrust_declared = manifest.acadrust_declared; - let acadrust_compatible = if !acadrust_declared { - // Legacy release that predates acadrust metadata: defer the - // decision to the repo-level policy below. + let acadrust_compatible = if manifest.api_version < ocs_plugin_api::API_VERSION { + // API versions below v4 predate the acadrust gate. + true + } else if !acadrust_declared { + // Legacy v4+ release without metadata: defer to repo-level policy. true } else { match acadrust_source.as_deref() { @@ -249,10 +251,9 @@ pub fn fetch_release_info(repo: &str) -> Result, String> { } // Repo-level policy: if any release in this repo declares an acadrust - // fingerprint, treat undeclared releases from the same repo as - // incompatible — but only for releases targeting the current host API - // generation (v4 and above). This preserves API v2/v3 backwards - // compatibility for legacy plugins that predate acadrust metadata. + // fingerprint, treat undeclared v4+ releases from the same repo as + // incompatible. API v2/v3 releases predate the gate and are preserved as + // legacy-compatible. let any_declared = info.iter().any(|r| r.acadrust_declared); if any_declared { for r in &mut info { @@ -333,7 +334,7 @@ pub fn install(release: &Release, repository: &str) -> Result { )); } - if manifest.acadrust_declared { + if manifest.api_version >= ocs_plugin_api::API_VERSION && manifest.acadrust_declared { let Some(source) = manifest.acadrust_source.as_deref() else { return Err( "Release declares acadrust metadata but has no source; cannot verify ABI compatibility".to_string(), From 8bb18562d21491a63e3b15907e94901c5d1185ef Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 24 Aug 2026 20:40:08 +0300 Subject: [PATCH 38/98] fix(donut): keep workflow state consistent --- src/entities/lwpolyline.rs | 54 +++++++++++---------------------- src/modules/draw/draw/donut.rs | 55 ++++++++++++++++++---------------- 2 files changed, 47 insertions(+), 62 deletions(-) diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 467d5cb7..d40417c9 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -16,6 +16,15 @@ use crate::scene::model::wire_model::TangentGeom; const TAU: f64 = std::f64::consts::TAU; const REVCLOUD_BULGE: f64 = 0.5; const MAX_REVCLOUD_VERTICES: usize = 100_000; +const WIDTH_EPSILON: f64 = 1.0e-9; + +fn effective_width(width: f64, constant_width: f64) -> f64 { + if width > WIDTH_EPSILON { + width + } else { + constant_width + } +} /// Midpoint position on an arc segment defined by its bulge. fn arc_midpoint(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> [f64; 2] { @@ -500,8 +509,8 @@ fn tapered_band_verts(pline: &LwPolyline) -> Option 1e-9 { v.start_width } else { c }; - let ew = if v.end_width > 1e-9 { v.end_width } else { c }; + let sw = effective_width(v.start_width, c); + let ew = effective_width(v.end_width, c); ([v.location.x, v.location.y], v.bulge, sw, ew) }) .collect(); @@ -852,23 +861,11 @@ fn properties(pline: &LwPolyline) -> Vec { let v = pline.vertices.get(vi); let vx = v.map_or(0.0, |v| v.location.x); let vy = v.map_or(0.0, |v| v.location.y); - // A constant width is the effective width of every segment whose - // per-vertex value is not overridden. Showing the raw zero stored on - // those vertices made wide polylines (notably rings) claim that their - // visible segment width was zero even though the global width was active. let start_w = v.map_or(0.0, |v| { - if v.start_width.abs() > 1.0e-12 { - v.start_width - } else { - pline.constant_width - } + effective_width(v.start_width, pline.constant_width) }); let end_w = v.map_or(0.0, |v| { - if v.end_width.abs() > 1.0e-12 { - v.end_width - } else { - pline.constant_width - } + effective_width(v.end_width, pline.constant_width) }); let mp = ::mass_props(pline); let cloud_arc_length = revision_cloud_arc_length(pline); @@ -1217,16 +1214,8 @@ impl crate::entities::traits::Grippable for LwPolyline { new_v.location.x = midpoint[0]; new_v.location.y = midpoint[1]; new_v.vertex_id = 0; - let effective_start = if v0.start_width > 1e-9 { - v0.start_width - } else { - self.constant_width - }; - let effective_end = if v0.end_width > 1e-9 { - v0.end_width - } else { - self.constant_width - }; + let effective_start = effective_width(v0.start_width, self.constant_width); + let effective_end = effective_width(v0.end_width, self.constant_width); let middle_width = (effective_start + effective_end) * 0.5; self.vertices[i0].end_width = middle_width; new_v.start_width = middle_width; @@ -1287,7 +1276,6 @@ pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> ([f64; 2], Vec< // Feeding the stored width in whole draws every wide polyline twice as wide // as the file asks for, which on a donut (a closed 2-vertex bulge-1 polyline) // shows up as a disc 1.5× its real radius. - let hw_const = pl.constant_width as f32 * 0.5; let verts = &pl.vertices; let n = verts.len(); if n < 2 { @@ -1299,16 +1287,8 @@ pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> ([f64; 2], Vec< for i in 0..seg_count { let v0 = &verts[i]; let v1 = &verts[(i + 1) % n]; - let hw0 = if v0.start_width > 1e-9 { - v0.start_width as f32 * 0.5 - } else { - hw_const - }; - let hw1 = if v0.end_width > 1e-9 { - v0.end_width as f32 * 0.5 - } else { - hw_const - }; + let hw0 = effective_width(v0.start_width, pl.constant_width) as f32 * 0.5; + let hw1 = effective_width(v0.end_width, pl.constant_width) as f32 * 0.5; if hw0 < 1e-6 && hw1 < 1e-6 { continue; } diff --git a/src/modules/draw/draw/donut.rs b/src/modules/draw/draw/donut.rs index c0a3611d..fe1fb841 100644 --- a/src/modules/draw/draw/donut.rs +++ b/src/modules/draw/draw/donut.rs @@ -1,19 +1,12 @@ -// DONUT command — create a filled circular ring (thick LwPolyline). -// -// A donut is an LwPolyline with: -// - 2 vertices at (cx ± r_avg, 0), both with bulge = 1.0 (two 180° CCW arcs) -// - constant_width = (outer - inner) / 2 -// - is_closed = true -// -// Workflow: -// 1. Type inner diameter (or 0 for a filled circle) -// 2. Type outer diameter -// 3. Click center point(s); Enter to finish +// DONUT creates a ring as a closed, constant-width LwPolyline. use acadrust::entities::{LwPolyline, LwVertex}; use acadrust::EntityType; -use cadkernel::geom2d::{Circle as KernelCircle, Curve as KernelCurve}; +use cadkernel::geom2d::{ + Circle as KernelCircle, Curve as KernelCurve, Vec2 as KernelVec2, +}; use glam::DVec3; + use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; @@ -44,8 +37,6 @@ impl DonutCommand { let mut outer_diameter = defaults::get_donut_outer_diameter(); if inner_diameter > outer_diameter { std::mem::swap(&mut inner_diameter, &mut outer_diameter); - defaults::set_donut_inner_diameter(inner_diameter); - defaults::set_donut_outer_diameter(outer_diameter); } Self { state: DonutState::AskInner, @@ -71,8 +62,23 @@ impl DonutCommand { } fn point_distance(&self, first: DVec3, second: DVec3) -> f64 { - let delta = self.plane.vector_to_local(second - first); - delta.x.hypot(delta.y) + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + KernelVec2::new(first.x, first.y).distance(KernelVec2::new(second.x, second.y)) + } + + fn project_to_plane(&self, point: DVec3) -> DVec3 { + let local = self.plane.to_local(point); + self.plane.to_world(DVec3::new(local.x, local.y, 0.0)) + } +} + +fn prompt_with_default(prompt: &str, value: f64) -> String { + match prompt.char_indices().next_back() { + Some((index, suffix @ (':' | ':'))) => { + format!("{} <{value:.4}>{suffix}", &prompt[..index]) + } + _ => format!("{prompt} <{value:.4}>"), } } @@ -92,10 +98,9 @@ impl CadCommand for DonutCommand { DonutState::AskInnerSecond(_) => { t!("DONUT Specify second point for inside diameter:").into_owned() } - DonutState::AskOuter => format!( - "{} <{:.4}>:", - t!("DONUT Specify outside diameter:").trim_end_matches(':'), - self.outer_r * 2.0 + DonutState::AskOuter => prompt_with_default( + t!("DONUT Specify outside diameter:").as_ref(), + self.outer_r * 2.0, ), DonutState::AskOuterSecond(_) => { t!("DONUT Specify second point for outside diameter:").into_owned() @@ -147,7 +152,7 @@ impl CadCommand for DonutCommand { fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.state { DonutState::AskInner => { - self.state = DonutState::AskInnerSecond(pt); + self.state = DonutState::AskInnerSecond(self.project_to_plane(pt)); CmdResult::NeedPoint } DonutState::AskInnerSecond(first) => { @@ -159,7 +164,7 @@ impl CadCommand for DonutCommand { CmdResult::NeedPoint } DonutState::AskOuter => { - self.state = DonutState::AskOuterSecond(pt); + self.state = DonutState::AskOuterSecond(self.project_to_plane(pt)); CmdResult::NeedPoint } DonutState::AskOuterSecond(first) => { @@ -197,9 +202,10 @@ impl CadCommand for DonutCommand { fn on_mouse_move(&mut self, pt: DVec3) -> Option { match self.state { DonutState::AskInnerSecond(first) | DonutState::AskOuterSecond(first) => { + let point = self.project_to_plane(pt); Some(WireModel::solid_f64( "rubber_band".into(), - vec![first.to_array(), pt.to_array()], + vec![first.to_array(), point.to_array()], WireModel::CYAN, false, )) @@ -257,8 +263,7 @@ fn make_donut(cx: f64, cy: f64, elevation: f64, inner_r: f64, outer_r: f64) -> E p.constant_width = width; p.elevation = elevation; - // Constant width is stored once on the polyline. Per-segment width fields - // stay zero so a later Global width edit controls the whole ring. + // Segment widths inherit the polyline's constant width. let mut v0 = LwVertex::new(Vector2::new(cx - r_avg, cy)); v0.bulge = 1.0; From d693bcf9686fd0251849b5866d1081de8c1288fb Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Mon, 24 Aug 2026 21:12:30 -0300 Subject: [PATCH 39/98] fix(mleader): improve grips, styles and annotative behavior --- src/app/command_driver.rs | 73 ++- src/app/layers.rs | 21 +- src/app/mtext_editor.rs | 19 + src/app/style_ops.rs | 190 +++++++- src/app/update/command.rs | 15 +- src/entities/multileader.rs | 667 +++++++++++++++++++++++----- src/modules/annotate/mleader_cmd.rs | 89 +++- src/scene/annotative.rs | 194 ++++++-- 8 files changed, 1077 insertions(+), 191 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 7c69d400..5f8eebb6 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1195,23 +1195,66 @@ impl OpenCADStudio { } } CmdResult::CommitAndEditText(entity) => { - let label = self.history_label_from_active_cmd(i, "ENTITY"); - let delta_safe = self.delta_add_safe(i, &entity); - let pending = self.begin_undo(i, label, 1, delta_safe); - let handle = self.commit_entity_handle(entity); - self.tabs[i].dirty = true; - self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.ribbon.deactivate_tool(); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); - } - if let Some(h) = handle { - return self.begin_text_edit(h); + // Una MLEADER cuyo estilo es anotativo necesita además del flag + // enable_annotation_scale un contexto real asociado a la escala + // anotativa actual. + let annotative_mleader = matches!( + &entity, + acadrust::EntityType::MultiLeader(ml) + if ml.enable_annotation_scale + ); + + let label = + self.history_label_from_active_cmd(i, "ENTITY"); + + // Crear un contexto anotativo agrega objetos/diccionarios al DWG, + // así que no puede tratarse como un simple delta de entidad. + let delta_safe = + self.delta_add_safe(i, &entity) + && !annotative_mleader; + + let pending = + self.begin_undo(i, label, 1, delta_safe); + + let handle = + self.commit_entity_handle(entity); + + // Registrar la representación de la escala actual. + if annotative_mleader { + if let (Some(handle), Some(scale_handle)) = ( + handle, + self.tabs[i] + .scene + .current_annotation_scale_handle(), + ) { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + handle, + scale_handle, + ); + + self.tabs[i].scene.bump_entities(&[( + handle, + crate::scene::ChangeKind::Modified, + )]); } } + + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.ribbon.deactivate_tool(); + + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } + + if let Some(h) = handle { + return self.begin_text_edit(h); + } + } CmdResult::CommitManyAndEditText { entities, edit_index, diff --git a/src/app/layers.rs b/src/app/layers.rs index 6f00ce26..6be69c8f 100644 --- a/src/app/layers.rs +++ b/src/app/layers.rs @@ -143,15 +143,22 @@ impl OpenCADStudio { } }) .collect(); - let active_mleader = self.tabs[i].active_mleader_style.clone(); - let active_mleader = if mleader_names.contains(&active_mleader) { - active_mleader - } else { + let current_mleader = + doc.header.current_mleader_style_name.clone(); + + let active_mleader = mleader_names - .first() + .iter() + .find(|name| { + name.eq_ignore_ascii_case( + ¤t_mleader + ) + }) .cloned() - .unwrap_or_default() - }; + .or_else(|| { + mleader_names.first().cloned() + }) + .unwrap_or_default(); let table_names: Vec = doc .objects diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index ce6d370c..35a6aafc 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -1248,6 +1248,17 @@ impl super::OpenCADStudio { } _ => {} } + // En una MLEADER anotativa, el texto forma parte del + // propio contexto por escala. Copiar la edición también + // a la representación que está actualmente en pantalla. + if matches!( + self.tabs[i].scene.document.get_entity(h), + Some(EntityType::MultiLeader(_)) + ) { + self.tabs[i] + .scene + .sync_displayed_annotation_context(h); + } self.tabs[i] .scene .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); @@ -1336,6 +1347,14 @@ impl super::OpenCADStudio { } _ => {} } + if matches!( + self.tabs[i].scene.document.get_entity(h), + Some(EntityType::MultiLeader(_)) + ) { + self.tabs[i] + .scene + .sync_displayed_annotation_context(h); + } self.tabs[i] .scene .bump_entities(&[(h, crate::scene::ChangeKind::Modified)]); diff --git a/src/app/style_ops.rs b/src/app/style_ops.rs index 0bdad07b..c12fc337 100644 --- a/src/app/style_ops.rs +++ b/src/app/style_ops.rs @@ -18,16 +18,135 @@ //! style added without a handle (dropped on DWG save, issue #67). use super::OpenCADStudio; -use acadrust::objects::{MLineStyle, MultiLeaderStyle, ObjectType, TableStyle}; +use acadrust::objects::{ + Dictionary, MLineStyle, MultiLeaderStyle, ObjectType, TableStyle, +}; use acadrust::tables::{DimStyle, TextStyle}; use acadrust::types::Handle; +const MLEADERSTYLE_DICT_NAME: &str = "ACAD_MLEADERSTYLE"; + +fn mleaderstyle_dict_handle(doc: &acadrust::CadDocument) -> Option { + let root_h = doc.header.named_objects_dict_handle; + + let root = match doc.objects.get(&root_h) { + Some(ObjectType::Dictionary(root)) => root, + _ => return None, + }; + + root.entries + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(MLEADERSTYLE_DICT_NAME)) + .map(|(_, handle)| *handle) + .filter(|handle| { + matches!( + doc.objects.get(handle), + Some(ObjectType::Dictionary(_)) + ) + }) +} + +fn import_mleaderstyle_names_from_dictionary(doc: &mut acadrust::CadDocument) { + let Some(dict_h) = mleaderstyle_dict_handle(doc) else { + return; + }; + + let entries = match doc.objects.get(&dict_h) { + Some(ObjectType::Dictionary(dict)) => dict.entries.clone(), + _ => return, + }; + + for (name, handle) in entries { + if let Some(ObjectType::MultiLeaderStyle(style)) = + doc.objects.get_mut(&handle) + { + style.name = name; + style.owner_handle = dict_h; + } + } +} + +fn sync_mleaderstyle_dictionary(doc: &mut acadrust::CadDocument) { + let root_h = crate::scene::annotative::root_named_dict_handle(doc); + + let existing = match doc.objects.get(&root_h) { + Some(ObjectType::Dictionary(root)) => root + .entries + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(MLEADERSTYLE_DICT_NAME)) + .map(|(_, handle)| *handle), + _ => None, + }; + + let dict_h = match existing.filter(|handle| { + matches!( + doc.objects.get(handle), + Some(ObjectType::Dictionary(_)) + ) + }) { + Some(handle) => handle, + None => { + let handle = doc.allocate_handle(); + + let mut dict = Dictionary::new(); + dict.handle = handle; + dict.owner = root_h; + + doc.objects + .insert(handle, ObjectType::Dictionary(dict)); + + handle + } + }; + + if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_h) { + root.entries + .retain(|(name, _)| !name.eq_ignore_ascii_case(MLEADERSTYLE_DICT_NAME)); + + root.add_entry(MLEADERSTYLE_DICT_NAME, dict_h); + } + + let mut entries: Vec<(String, Handle)> = doc + .objects + .iter() + .filter_map(|(&handle, object)| match object { + ObjectType::MultiLeaderStyle(style) => { + Some((style.name.clone(), handle)) + } + _ => None, + }) + .collect(); + + entries.sort_by(|a, b| { + a.0.to_lowercase().cmp(&b.0.to_lowercase()) + }); + + for (_, handle) in &entries { + if let Some(ObjectType::MultiLeaderStyle(style)) = + doc.objects.get_mut(handle) + { + style.owner_handle = dict_h; + } + } + + if let Some(ObjectType::Dictionary(dict)) = doc.objects.get_mut(&dict_h) { + dict.owner = root_h; + dict.entries = entries; + + // AutoCAD stores MLEADERSTYLE entries as soft-owner references (350), + // not hard-owner references (360). + dict.hard_owner = false; + dict.hard_owner_entries.clear(); + } +} + /// Guarantee the built-in "Standard" style of every kind exists in `doc` — /// a foreign or damaged file saved without them leaves the style dropdowns /// broken with no way to recover, and new text/dimensions have nothing to /// reference (#366). Missing entries are re-seeded with the app defaults. /// Called on every file open; a no-op for healthy documents. pub(crate) fn ensure_standard_styles(doc: &mut acadrust::CadDocument) { + import_mleaderstyle_names_from_dictionary(doc); if !doc .text_styles .iter() @@ -68,6 +187,8 @@ pub(crate) fn ensure_standard_styles(doc: &mut acadrust::CadDocument) { s.handle = doc.allocate_handle(); doc.objects.insert(s.handle, ObjectType::MultiLeaderStyle(s)); } + sync_mleaderstyle_dictionary(doc); + if !has(doc, |o| match o { ObjectType::MLineStyle(s) => Some(&s.name), _ => None, @@ -741,6 +862,7 @@ impl OpenCADStudio { for (h, o) in &snap.style_objects { doc.objects.insert(*h, o.clone()); } + sync_mleaderstyle_dictionary(doc); doc.header.current_text_style_name = snap.current_text.clone(); doc.header.current_dimstyle_name = snap.current_dim.clone(); doc.header.multiline_style = snap.multiline_style.clone(); @@ -772,11 +894,77 @@ impl OpenCADStudio { self.sync_ribbon_styles(); return; }; + sync_mleaderstyle_dictionary( + &mut self.tabs[i].scene.document, + ); + let edited = self.capture_style_state(); let changed = edited != stage.baseline; if changed { self.tabs[i].dirty = true; let (text_names, dim_names, object_handles) = edited.changed_keys(&stage.baseline); + let changed_mleader_styles: + Vec = + object_handles + .iter() + .filter_map(|handle| { + match self.tabs[i] + .scene + .document + .objects + .get(handle) + { + Some( + acadrust::objects::ObjectType:: + MultiLeaderStyle(style), + ) => Some(style.clone()), + _ => None, + } + }) + .collect(); + + let mut changed_mleaders = Vec::new(); + + for style in changed_mleader_styles { + let entity_handles: Vec = { + let doc = &self.tabs[i].scene.document; + + doc.entities() + .filter_map(|entity| { + match entity { + acadrust::EntityType::MultiLeader(ml) + if ml.style_handle + == Some(style.handle) => + { + Some(ml.common.handle) + } + _ => None, + } + }) + .collect() + }; + + for handle in entity_handles { + if crate::scene::annotative:: + apply_mleader_style_to_object( + &mut self.tabs[i].scene.document, + handle, + &style, + ) + { + changed_mleaders.push(( + handle, + crate::scene::ChangeKind::Modified, + )); + } + } + } + + if !changed_mleaders.is_empty() { + self.tabs[i] + .scene + .bump_entities(&changed_mleaders); + } self.tabs[i].scene.invalidate_text_style_dependencies_many(&text_names); self.tabs[i] .scene diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 3ecaec32..09de2535 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1672,9 +1672,20 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } StyleKey::MLeaderStyle => { - self.ribbon.active_mleader_style = name.clone(); let i = self.active_tab; - self.tabs[i].active_mleader_style = name; + + self.ribbon.active_mleader_style = name.clone(); + self.tabs[i].active_mleader_style = name.clone(); + + // Esta es la fuente que consulta el comando MLEADER + // al crear una entidad nueva. + self.tabs[i] + .scene + .document + .header + .current_mleader_style_name = name; + + self.tabs[i].dirty = true; } StyleKey::TableStyle => { self.ribbon.active_table_style = name; diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index d2568013..65b85eec 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -182,8 +182,18 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option= 0.0 { 0.0 } else { 1.0 }, - v_anchor: mleader_v_anchor(ctx.text_left_attachment), + attach_h_anchor: match ctx.text_attachment_point { + acadrust::entities::multileader::TextAttachmentPointType::Left => 0.0, + acadrust::entities::multileader::TextAttachmentPointType::Center => 0.5, + acadrust::entities::multileader::TextAttachmentPointType::Right => 1.0, + }, + v_anchor: mleader_v_anchor( + if text_sign >= 0.0 { + ctx.text_left_attachment + } else { + ctx.text_right_attachment + }, + ), line_spacing_factor: ctx.line_spacing_factor as f32, vertical_text: false, want_glyph_boxes: false, @@ -200,11 +210,21 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option Option = line.points.iter().map(|p| p3(p)).collect(); - let last_f = *ctrl.last().unwrap_or(&landing_pt); - let dist = ((last_f[0] - landing_pt[0]).powi(2) - + (last_f[1] - landing_pt[1]).powi(2)) + let mut ctrl: Vec<[f64; 3]> = + line.points.iter().map(|p| p3(p)).collect(); + + let last_f = *ctrl.last().unwrap_or(&elbow_pt); + + let dist = ((last_f[0] - elbow_pt[0]).powi(2) + + (last_f[1] - elbow_pt[1]).powi(2)) .sqrt(); + if dist > 1e-9 { - ctrl.push(landing_pt); + ctrl.push(elbow_pt); } for &c in &ctrl { key_verts.push(c); @@ -295,8 +319,8 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option 0.0 { points.push(nan); - points.push(landing_pt); - points.push([text_loc.x, text_loc.y, text_loc.z]); + points.push(elbow_pt); + points.push(text_edge_pt); } } @@ -428,27 +452,134 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) { ], ) } +fn mleader_landing_geom( + ml: &MultiLeader, +) -> Option<(DVec3, DVec3, f64, f64, f64)> { + if ml.content_type != LeaderContentType::MText + || !ml.enable_landing + || !ml.enable_dogleg + { + return None; + } + let text = DVec3::new( + ml.context.text_location.x, + ml.context.text_location.y, + ml.context.text_location.z, + ); + + let td = ml.context.text_direction; + + let axis = { + let d = DVec3::new(td.x, td.y, td.z); + + if d.length_squared() > 1.0e-18 { + d.normalize() + } else { + let a = ml.context.text_rotation; + + DVec3::new(a.cos(), a.sin(), 0.0) + } + }; + + let leader_ref = ml + .context + .leader_roots + .first() + .and_then(|root| root.lines.first()) + .and_then(|line| line.points.last()) + .map(|p| DVec3::new(p.x, p.y, p.z)) + .or_else(|| { + ml.context.leader_roots.first().map(|root| { + DVec3::new( + root.connection_point.x, + root.connection_point.y, + root.connection_point.z, + ) + }) + }) + .unwrap_or(text); + + let sign = if (text - leader_ref).dot(axis) >= 0.0 { + 1.0 + } else { + -1.0 + }; + + let dogleg = ml + .context + .leader_roots + .first() + .map(|root| root.landing_distance.max(0.0)) + .unwrap_or_else(|| ml.dogleg_length.max(0.0)); + + let gap = ml.context.landing_gap.max(0.0); + + if dogleg <= 1.0e-9 { + return None; + } + + // El codo visual queda ANTES del tramo horizontal y del gap al texto. + let elbow = + text - axis * (sign * (dogleg + gap)); + + Some((elbow, axis, sign, dogleg, gap)) +} fn grips(ml: &MultiLeader) -> Vec { - let mut result: Vec = Vec::new(); + let mut result = Vec::new(); let mut id = 0usize; + // Grips reales de las líneas: + // flecha + cualquier vértice intermedio. for root in &ml.context.leader_roots { for line in &root.lines { for p in &line.points { - result.push(square_grip(id, glam::DVec3::new(p.x, p.y, p.z))); + result.push(square_grip( + id, + DVec3::new(p.x, p.y, p.z), + )); + id += 1; } } } if ml.content_type == LeaderContentType::MText { + // Grip virtual del codo. + if let Some((elbow, _, _, _, _)) = mleader_landing_geom(ml) + { + result.push(square_grip( + id, + elbow, + )); + + id += 1; + } + + // Extremo del landing. + // + // Coincide geométricamente con el punto donde + // comienza el contenido. let tl = &ml.context.text_location; - // Text-location grip, then the wrap-width grip at the box's far edge. - result.push(center_grip(id, glam::DVec3::new(tl.x, tl.y, tl.z))); + + result.push(center_grip( + id, + DVec3::new(tl.x, tl.y, tl.z), + )); + id += 1; + + // Grip triangular del ancho del MTEXT. let (_, far) = text_box_geom(ml); - result.push(triangle_grip(id, glam::DVec3::new(far[0], far[1], far[2]))); + + result.push(triangle_grip( + id, + DVec3::new( + far[0], + far[1], + far[2], + ), + )); } result @@ -458,16 +589,30 @@ fn grips(ml: &MultiLeader) -> Vec { /// text grip's "Move with Leader" action so the leader follows the text. pub(crate) const MOVE_ALL_GRIP: usize = usize::MAX; -fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) { +fn apply_grip( + ml: &mut MultiLeader, + grip_id: usize, + apply: GripApply, +) { + // Movimiento completo de la MLEADER. if grip_id == MOVE_ALL_GRIP { let (dx, dy, dz) = match apply { - GripApply::Translate(d) => (d.x as f64, d.y as f64, d.z as f64), + GripApply::Translate(d) => ( + d.x as f64, + d.y as f64, + d.z as f64, + ), + GripApply::Absolute(a) => ( - a.x as f64 - ml.context.text_location.x, - a.y as f64 - ml.context.text_location.y, - a.z as f64 - ml.context.text_location.z, + a.x as f64 + - ml.context.text_location.x, + a.y as f64 + - ml.context.text_location.y, + a.z as f64 + - ml.context.text_location.z, ), }; + for root in &mut ml.context.leader_roots { for line in &mut root.lines { for p in &mut line.points { @@ -476,18 +621,24 @@ fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) { p.z += dz; } } + root.connection_point.x += dx; root.connection_point.y += dy; root.connection_point.z += dz; } + ml.context.text_location.x += dx; ml.context.text_location.y += dy; ml.context.text_location.z += dz; + return; } let mut idx = 0usize; + // ───────────────────────────────────────────── + // GRIPS DE LA LÍNEA + // ───────────────────────────────────────────── for root in &mut ml.context.leader_roots { for line in &mut root.lines { for p in &mut line.points { @@ -498,55 +649,270 @@ fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) { p.y = a.y as f64; p.z = a.z as f64; } + GripApply::Translate(d) => { p.x += d.x as f64; p.y += d.y as f64; p.z += d.z as f64; } } + return; } + idx += 1; } } } - // Text-location grip (idx == n_vertices), then the wrap-width grip. - if ml.content_type == LeaderContentType::MText { - if idx == grip_id { - let tl = &mut ml.context.text_location; - match apply { - GripApply::Absolute(a) => { - tl.x = a.x as f64; - tl.y = a.y as f64; - tl.z = a.z as f64; - } - GripApply::Translate(d) => { - tl.x += d.x as f64; - tl.y += d.y as f64; - tl.z += d.z as f64; - } + if ml.content_type != LeaderContentType::MText { + return; + } + + // Calculamos la geometría antes de modificar nada. + let landing = + mleader_landing_geom(ml); + + // ───────────────────────────────────────────── + // GRIP DEL CODO + // ───────────────────────────────────────────── + if let Some((old_elbow, axis, old_sign, dogleg, gap)) = landing { + if grip_id == idx { + let target = match apply { + GripApply::Absolute(a) => { + DVec3::new( + a.x as f64, + a.y as f64, + a.z as f64, + ) } + + GripApply::Translate(d) => { + old_elbow + + DVec3::new( + d.x as f64, + d.y as f64, + d.z as f64, + ) + } + }; + + // Punto de flecha de la directriz. + // + // Usamos el primer punto de la primera LeaderLine, + // que corresponde a la punta de flecha. + let arrow = ml + .context + .leader_roots + .first() + .and_then(|root| root.lines.first()) + .and_then(|line| line.points.first()) + .map(|p| { + DVec3::new( + p.x, + p.y, + p.z, + ) + }) + .unwrap_or(old_elbow); + + // Posición del codo respecto de la flecha, + // proyectada sobre la dirección horizontal + // propia de la MLEADER. + // + // Esto funciona también con UCS girado. + let side_distance = + (target - arrow).dot(axis); + + // Histeresis: no espejar apenas cruza 1 px. + // Tiene que pasarse un 10% del largo del dogleg. + let flip_tol = + (dogleg * 0.10).max(1.0e-6); + + let new_sign = + if side_distance > flip_tol { + 1.0 + } else if side_distance < -flip_tol { + -1.0 + } else { + old_sign + }; + // Cambiar el borde de attachment, NO la justificación interna + // del texto. + // + // Texto a la derecha -> se conecta por su borde izquierdo. + // Texto a la izquierda -> se conecta por su borde derecho. + ml.context.text_attachment_point = + if new_sign >= 0.0 { + acadrust::entities::multileader::TextAttachmentPointType::Left + } else { + acadrust::entities::multileader::TextAttachmentPointType::Right + }; + // El texto se coloca a una distancia: + // dogleg + landing_gap + // + // Así el codo sigue coincidiendo con el grip, + // y además queda un espacio limpio antes del texto. + let new_text = + target + + axis + * (new_sign * (dogleg + gap)); + + ml.context.text_location.x = + new_text.x; + + ml.context.text_location.y = + new_text.y; + + ml.context.text_location.z = + new_text.z; + + // Mantener coherente la información de conexión + // almacenada por el MLEADER. + if let Some(root) = + ml.context.leader_roots.first_mut() + { + root.connection_point.x = + target.x; + + root.connection_point.y = + target.y; + + root.connection_point.z = + target.z; + + root.direction.x = + axis.x * new_sign; + + root.direction.y = + axis.y * new_sign; + + root.direction.z = + axis.z * new_sign; + } + + return; + } + + idx += 1; + + // ───────────────────────────────────────── + // GRIP DEL EXTREMO HORIZONTAL + // ───────────────────────────────────────── + if grip_id == idx { + let Some(( + elbow, + axis, + sign, + _old_length, + gap, + )) = mleader_landing_geom(ml) + else { + return; + }; + + let old_end = DVec3::new( + ml.context.text_location.x, + ml.context.text_location.y, + ml.context.text_location.z, + ); + + let target = match apply { + GripApply::Absolute(a) => { + DVec3::new( + a.x as f64, + a.y as f64, + a.z as f64, + ) + } + + GripApply::Translate(d) => { + old_end + + DVec3::new( + d.x as f64, + d.y as f64, + d.z as f64, + ) + } + }; + + // Proyección exclusivamente sobre la + // horizontal propia de la MLEADER. + // El grip está en text_location, no en el final de la línea. + // Por eso restamos el gap para obtener el largo real del dogleg. + let requested_total = + (target - elbow).dot(axis) * sign; + + let new_length = + (requested_total - gap).max(1.0e-6); + + let new_text = + elbow + + axis + * (sign * (new_length + gap)); + + ml.context.text_location.x = + new_text.x; + + ml.context.text_location.y = + new_text.y; + + ml.context.text_location.z = + new_text.z; + + // landing_distance es justamente la longitud + // del dogleg de la MLEADER. + ml.dogleg_length = + new_length; + + for root in &mut ml.context.leader_roots { + root.landing_distance = + new_length; + } + return; } + idx += 1; - if idx == grip_id { - // Dragging the box edge sets the MText wrap limit, projected on - // the box's flow axis (rotated baseline, or down the column for - // vertical flow). - let (dir, far) = text_box_geom(ml); - let (nx, ny) = match apply { - GripApply::Absolute(a) => (a.x as f64, a.y as f64), - GripApply::Translate(d) => (far[0] + d.x as f64, far[1] + d.y as f64), - }; - let tl = &ml.context.text_location; - // `dir` carries the attachment-side factor (may be ±half), so - // normalise by its squared length: width = proj(flow)/k. - let d2 = (dir[0] * dir[0] + dir[1] * dir[1]).max(1e-12); - let proj = ((nx - tl.x) * dir[0] + (ny - tl.y) * dir[1]) / d2; - let min_w = ml.text_height.max(1.0) * 0.5; - ml.context.text_width = proj.max(min_w); - } + } + + // ───────────────────────────────────────────── + // GRIP DEL ANCHO DEL TEXTO + // ───────────────────────────────────────────── + if grip_id == idx { + let (dir, far) = + text_box_geom(ml); + + let (nx, ny) = match apply { + GripApply::Absolute(a) => ( + a.x as f64, + a.y as f64, + ), + + GripApply::Translate(d) => ( + far[0] + d.x as f64, + far[1] + d.y as f64, + ), + }; + + let tl = + &ml.context.text_location; + + let d2 = + (dir[0] * dir[0] + + dir[1] * dir[1]) + .max(1.0e-12); + + let proj = + ((nx - tl.x) * dir[0] + + (ny - tl.y) * dir[1]) + / d2; + + let min_w = + ml.text_height.max(1.0) * 0.5; + + ml.context.text_width = + proj.max(min_w); } } @@ -1133,53 +1499,87 @@ impl crate::entities::traits::Grippable for MultiLeader { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } - fn grip_menu(&self, grip_id: usize) -> Vec { - use crate::scene::model::object::{GripMenuAction, GripMenuItem}; + fn grip_menu( + &self, + grip_id: usize, + ) -> Vec { + use crate::scene::model::object::{ + GripMenuAction, + GripMenuItem, + }; + let n_vertices: usize = self .context .leader_roots .iter() - .flat_map(|r| r.lines.iter()) - .map(|l| l.points.len()) + .flat_map(|root| root.lines.iter()) + .map(|line| line.points.len()) .sum(); - if self.content_type == LeaderContentType::MText && grip_id >= n_vertices { - if grip_id == n_vertices { - // Text-location grip. - vec![ + + if self.content_type + == LeaderContentType::MText + { + let has_elbow = + mleader_landing_geom(self).is_some(); + + let elbow_id = + n_vertices; + + let landing_id = + n_vertices + + usize::from(has_elbow); + + let width_id = + landing_id + 1; + + // Codo. + if has_elbow + && grip_id == elbow_id + { + return vec![ GripMenuItem { label: "Stretch", - action: GripMenuAction::Stretch, + action: + GripMenuAction::Stretch, }, - GripMenuItem { - label: "Move with Leader", - action: GripMenuAction::MoveWithLeader, - }, - GripMenuItem { - label: "Move Independent", - action: GripMenuAction::MoveIndependent, - }, - ] - } else { - // Wrap-width grip: drag only. - Vec::new() + ]; + } + + // Extremo horizontal. + if grip_id == landing_id { + return vec![ + GripMenuItem { + label: "Stretch", + action: + GripMenuAction::Stretch, + }, + ]; + } + + // Ancho del texto. + if grip_id == width_id { + return Vec::new(); } - } else { - // Leader-line vertex. - vec![ - GripMenuItem { - label: "Stretch", - action: GripMenuAction::Stretch, - }, - GripMenuItem { - label: "Add Leader", - action: GripMenuAction::AddLeader, - }, - GripMenuItem { - label: "Remove Leader", - action: GripMenuAction::RemoveLeader, - }, - ] } + + // Flecha / vértices de la línea. + vec![ + GripMenuItem { + label: "Stretch", + action: + GripMenuAction::Stretch, + }, + GripMenuItem { + label: "Add Leader", + action: + GripMenuAction::AddLeader, + }, + GripMenuItem { + label: "Remove Leader", + action: + GripMenuAction::RemoveLeader, + }, + ] } fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { use crate::scene::model::object::GripMenuAction as A; @@ -1323,16 +1723,53 @@ impl MultiLeaderTess for MultiLeader { // ── Scaling ────────────────────────────────────────────────────────────── // Used only when a context omits an already-resolved content size. - let fallback_content_scale = (ml.scale_factor as f32) - * if crate::scene::annotative::mleader_is_annotative(document, ml) { - anno_scale + // ── Scaling ────────────────────────────────────────────────────────────── + + let annotative = + crate::scene::annotative::mleader_is_annotative(document, ml); + + // MLEADER context data stores sizes already resolved for the annotation + // scale at which that context was created. + // + // If the current CANNOSCALE is different, apply only the ratio between + // the requested scale and the context's stored scale. + // + // Example: + // context created at 1:200 -> context.scale_factor = 200 + // current scale 1:100 -> correction = 100 / 200 = 0.5 + // + // If an AutoCAD file already supplies a real 1:100 context, its + // scale_factor is already 100 and the correction naturally becomes 1. + let base_scale = if ml.scale_factor.abs() > 1.0e-12 { + ml.scale_factor as f32 + } else { + 1.0 + }; + + let stored_context_scale = ml.context.scale_factor as f32; + + let context_scale_correction = + if annotative && stored_context_scale.abs() > 1.0e-12 { + let correction = + (base_scale * anno_scale) / stored_context_scale; + + if correction.is_finite() && correction > 0.0 { + correction + } else { + 1.0 + } } else { 1.0 }; + let fallback_content_scale = + base_scale * if annotative { anno_scale } else { 1.0 }; + // The active context stores the resolved world-space arrow size. // Reapplying the entity scale here makes context-sized arrows grow twice. - let arrow_size = ml.context.arrowhead_size as f32; + let arrow_size = + ml.context.arrowhead_size as f32 + * context_scale_correction; let draw_arrow = arrow_size > 0.0; let invisible = ml.path_type == MultiLeaderPathType::Invisible; // arrowhead_handle resolves through the block records to a named arrow @@ -1357,7 +1794,27 @@ impl MultiLeaderTess for MultiLeader { // Which side the text grip sits on, recomputed every frame so the text // alignment and the landing mirror live when the arrow or text moves. - let text_loc_w = ml.context.text_location; + let mut text_loc_w = ml.context.text_location; + + // Keep the leader elbow fixed in model space and scale only the + // annotation-side offset. This makes text + landing follow CANNOSCALE + // without moving the arrowhead or the user-defined leader geometry. + if annotative + && (context_scale_correction - 1.0).abs() > 1.0e-6 + { + if let Some(root) = ml.context.leader_roots.first() { + let anchor = root.connection_point; + let k = context_scale_correction as f64; + + text_loc_w = acadrust::types::Vector3::new( + anchor.x + + (text_loc_w.x - anchor.x) * k, + anchor.y + + (text_loc_w.y - anchor.y) * k, + text_loc_w.z, + ); + } + } let leader_ref_w = ml .context .leader_roots @@ -1471,7 +1928,9 @@ impl MultiLeaderTess for MultiLeader { // stray line up the side of the text. // Landing distance belongs to the selected leader-root context // and is already resolved in world units. - let d = root.landing_distance; + let d = + root.landing_distance + * context_scale_correction as f64; // The dogleg runs along the leader root's stored direction — // for a rotated leader that is the angled baseline, not world // X. Roots without a usable direction keep the legacy @@ -1633,11 +2092,13 @@ impl MultiLeaderTess for MultiLeader { // scale_factor + annotation scale applied. let height = if ctx.text_height > 0.0 { ctx.text_height as f32 + * context_scale_correction } else { - ml.text_height as f32 * fallback_content_scale + ml.text_height as f32 + * fallback_content_scale }; - let ins = &ctx.text_location; + let ins = &text_loc_w; // Subtract world_offset in f64 before casting to f32: drawings often // sit at large absolute coordinates and casting first then subtracting // throws away the precision needed for the rotated sub-glyph offsets. @@ -1730,7 +2191,9 @@ impl MultiLeaderTess for MultiLeader { value: &ctx.text_string, insertion: [local_ins_x as f64, local_ins_y as f64, z as f64], height, - rect_w: ctx.text_width as f32, + rect_w: + ctx.text_width as f32 + * context_scale_correction, rotation: rot, style: &style, attach_h_anchor: h_anchor, diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index 383a8d66..e138e4a2 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -74,17 +74,34 @@ impl CadCommand for MLeaderCommand { if self.verts.is_empty() { t!("MLEADER Specify arrowhead point:").into_owned() } else { - t!( - "MLEADER Specify next point [%{count} pts — Enter to place text]:", - count = self.verts.len() - ) - .into_owned() + t!("MLEADER Specify landing point:").into_owned() } } fn on_point(&mut self, pt: DVec3) -> CmdResult { self.verts.push(pt); - CmdResult::NeedPoint + + if self.verts.len() < 2 { + return CmdResult::NeedPoint; + } + + let local: Vec = self + .verts + .iter() + .map(|point| self.plane.to_local(*point)) + .collect(); + + let ml = build_mleader( + "", + &local, + Mat4::IDENTITY, + self.style.as_ref(), + self.display_scale, + ); + + CmdResult::CommitAndEditText( + self.plane.place_entity(EntityType::MultiLeader(ml)), + ) } fn on_enter(&mut self) -> CmdResult { @@ -163,13 +180,23 @@ fn build_mleader( display_scale: f64, ) -> MultiLeader { // Last vertex = content/text location; remaining = leader line points - let (leader_pts, content_pt) = verts.split_at(verts.len() - 1); - let content_pt = content_pt[0]; + // Standard MLEADER creation: + // verts[0] = arrowhead point + // verts[1] = elbow / leader-root connection point + let arrow_pt = verts[0]; + let elbow_pt = verts[1]; - let leader_v3: Vec = leader_pts.iter().map(|p| v3(*p)).collect(); - let content_v3 = v3(content_pt); + let arrow_v3 = v3(arrow_pt); + let elbow_v3 = v3(elbow_pt); - let mut ml = MultiLeader::with_text(text, content_v3, leader_v3); + // Start with one native leader line. + // The text/content position is corrected below once the dogleg + // direction and landing distance are known. + let mut ml = MultiLeader::with_text( + text, + elbow_v3, + vec![arrow_v3], + ); if let Some(style) = style { crate::scene::annotative::apply_mleader_style(&mut ml, style); } else { @@ -190,10 +217,21 @@ fn build_mleader( // world), so the annotation reads square to the user's coordinate system. let ux = ucs.transform_vector3(Vec3::X).normalize_or(Vec3::X); // Which side of the leader the text sits on, measured along the UCS X axis. - let last_leader = leader_pts.last().copied().unwrap_or(content_pt); - let to_right = (content_pt - last_leader).dot(ux.as_dvec3()) >= 0.0; + let to_right = (elbow_pt - arrow_pt).dot(ux.as_dvec3()) >= 0.0; let sign = if to_right { 1.0 } else { -1.0 }; let landing = ux * (sign as f32); + // text_location es el punto de unión del contenido con la directriz. + // + // Si el texto está a la derecha, el punto de inserción corresponde + // al borde IZQUIERDO del texto. + // + // Si está a la izquierda, corresponde al borde DERECHO. + ml.context.text_attachment_point = + if to_right { + acadrust::entities::multileader::TextAttachmentPointType::Left + } else { + acadrust::entities::multileader::TextAttachmentPointType::Right + }; // Text + landing read along the UCS X axis. text_direction is what the // renderer consults first, so set both. @@ -201,18 +239,29 @@ fn build_mleader( ml.context.text_direction = Vector3::new(ux.x as f64, ux.y as f64, 0.0); if let Some(root) = ml.context.leader_roots.first_mut() { - // Leader ends at the clicked point; the landing runs from there toward - // the text along the UCS X axis. - root.direction = Vector3::new(landing.x as f64, landing.y as f64, 0.0); - root.connection_point = content_v3; + root.direction = + Vector3::new(landing.x as f64, landing.y as f64, 0.0); + + // The second click is the elbow/root connection. + root.connection_point = elbow_v3; + + // Dogleg length from the active MLeaderStyle. root.landing_distance = landing_distance; } // Seed the text one landing-length past the leader end, on the side the // user dragged toward, offset along the UCS X axis. - let off = landing * (landing_distance + landing_gap) as f32; - ml.context.text_location = - Vector3::new(content_v3.x + off.x as f64, content_v3.y + off.y as f64, content_v3.z); + let off = + landing * (landing_distance + landing_gap) as f32; + + let text_location = Vector3::new( + elbow_v3.x + off.x as f64, + elbow_v3.y + off.y as f64, + elbow_v3.z, + ); + + ml.context.text_location = text_location; + ml.context.content_base_point = text_location; ml } diff --git a/src/scene/annotative.rs b/src/scene/annotative.rs index 0c887277..9172e46d 100644 --- a/src/scene/annotative.rs +++ b/src/scene/annotative.rs @@ -697,25 +697,16 @@ pub fn effective_annotation_scale_for( // already-scaled text height and overall scale factor. Keep the text // height as stored; make `ml.scale_factor * anno_scale` resolve to the // active context's scale factor for arrows, doglegs, and fallback text. - if let EntityType::MultiLeader(mleader) = entity { - let Some(active) = - active_object_context_for_scale(doc, entity.common().handle, scale_handle) - else { - return fallback; - }; - let ObjectContextKind::MLeader(context) = &active.kind else { - return fallback; - }; - let base = mleader.scale_factor; - if base.abs() <= 1.0e-12 { - return fallback; - } - let relative = context.scale_factor / base; - return if relative.is_finite() && relative > 0.0 { - relative as f32 - } else { - fallback - }; + // MLEADER needs the absolute current annotation multiplier here. + // + // Its stored context may belong to another annotation scale. The MLEADER + // tessellator compares this current multiplier with the context's stored + // scale_factor and applies only the required correction. + // + // When the active context already belongs to this scale the correction is 1, + // preserving AutoCAD per-scale context geometry unchanged. + if matches!(entity, EntityType::MultiLeader(_)) { + return fallback; } let Some(coll_h) = annotation_scales_dict(doc, entity.common().handle) else { @@ -1586,47 +1577,162 @@ pub fn apply_mleader_style( } } } +fn apply_mleader_style_at_display_scale( + entity: &mut acadrust::entities::MultiLeader, + style: &acadrust::objects::MultiLeaderStyle, + display_scale: f64, +) { + apply_mleader_style(entity, style); + + let scale = if display_scale.is_finite() + && display_scale > 1.0e-12 + { + display_scale + } else { + 1.0 + }; + + // Estos valores del estilo son tamaños de papel. + // El contexto MLEADER guarda la representación ya + // escalada para la escala anotativa/modelo activa. + entity.context.scale_factor = scale; + + entity.context.text_height = + style.text_height * scale; + + entity.context.arrowhead_size = + style.arrowhead_size * scale; + + entity.context.landing_gap = + style.landing_gap * scale; + + for root in &mut entity.context.leader_roots { + root.landing_distance = + style.landing_distance * scale; + + root.text_attachment_direction = + entity.text_attachment_direction; + } +} pub fn apply_mleader_style_to_object( doc: &mut CadDocument, handle: Handle, style: &acadrust::objects::MultiLeaderStyle, ) -> bool { - let Some(EntityType::MultiLeader(original)) = doc.get_entity(handle).cloned() else { + let Some(EntityType::MultiLeader(original)) = + doc.get_entity(handle).cloned() + else { return false; }; + + // Escala visual de la representación base. + // + // En una MLEADER anotativa context.scale_factor + // contiene la escala real de esa representación + // (por ejemplo 50 para 1:50). + // + // En una no anotativa manda el scale_factor del estilo. + let base_display_scale = if style.is_annotative { + let scale = original.context.scale_factor; + + if scale.is_finite() && scale > 1.0e-12 { + scale + } else { + 1.0 + } + } else { + let scale = style.scale_factor; + + if scale.is_finite() && scale > 1.0e-12 { + scale + } else { + 1.0 + } + }; + let mut styled = original.clone(); - apply_mleader_style(&mut styled, style); - if let Some(EntityType::MultiLeader(entity)) = doc.get_entity_mut(handle) { + + apply_mleader_style_at_display_scale( + &mut styled, + style, + base_display_scale, + ); + + if let Some(EntityType::MultiLeader(entity)) = + doc.get_entity_mut(handle) + { *entity = styled; } - let leaf_handles: Vec<_> = annotation_scales_dict(doc, handle) - .and_then(|collection| as_dict(doc, collection)) - .map(|collection| collection.entries.iter().map(|(_, leaf)| *leaf).collect()) - .unwrap_or_default(); + // Actualizar también TODAS las representaciones + // anotativas almacenadas de la misma MLEADER. + let leaf_handles: Vec<_> = + annotation_scales_dict(doc, handle) + .and_then(|collection| as_dict(doc, collection)) + .map(|collection| { + collection + .entries + .iter() + .map(|(_, leaf)| *leaf) + .collect() + }) + .unwrap_or_default(); + for leaf_handle in leaf_handles { - let Some(ObjectType::ObjectContextData(leaf)) = doc.objects.get_mut(&leaf_handle) else { - continue; - }; - let ObjectContextKind::MLeader(context) = &mut leaf.kind else { - continue; - }; - let context_scale = context.scale_factor; - let text_height_ratio = if original.text_height.abs() > 1.0e-12 { - context.text_height / original.text_height - } else { - 1.0 - }; + // Primero clonamos los datos necesarios para + // evitar mantener un borrow mutable sobre doc. + let context_before = + match doc.objects.get(&leaf_handle) { + Some( + ObjectType::ObjectContextData(leaf) + ) => { + let ObjectContextKind::MLeader(context) = + &leaf.kind + else { + continue; + }; + + context.clone() + } + + _ => continue, + }; + + let context_scale = + if context_before.scale_factor.is_finite() + && context_before.scale_factor > 1.0e-12 + { + context_before.scale_factor + } else { + base_display_scale + }; + let mut per_scale = original.clone(); - per_scale.context.clone_from(context); - apply_mleader_style(&mut per_scale, style); - per_scale.context.scale_factor = context_scale; - if style.text_height > 0.0 && text_height_ratio.is_finite() { - per_scale.context.text_height = style.text_height * text_height_ratio; + + per_scale.context = + context_before; + + apply_mleader_style_at_display_scale( + &mut per_scale, + style, + context_scale, + ); + + if let Some( + ObjectType::ObjectContextData(leaf) + ) = doc.objects.get_mut(&leaf_handle) + { + if let ObjectContextKind::MLeader(context) = + &mut leaf.kind + { + context.clone_from( + &per_scale.context, + ); + } } - context.clone_from(&per_scale.context); } + true } From 2846ba6afc4054d3a732d9ccc826371899fefdfb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:52:27 +0300 Subject: [PATCH 40/98] Support aligned dimension associations --- src/app/command_driver.rs | 95 ++++++++++++----- src/command.rs | 8 +- src/modules/annotate/linear_dim.rs | 40 +++++++- src/scene/dimension_assoc.rs | 159 ++++++++++++++++++++++++----- 4 files changed, 242 insertions(+), 60 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bf03b591..21061fd0 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -801,19 +801,24 @@ impl OpenCADStudio { // the structure snapshot fallback. let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - let is_linear_dimension = matches!( + let is_associative_dimension = matches!( entity, - acadrust::EntityType::Dimension(acadrust::entities::Dimension::Linear(_)) + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + ) ); + let association_enabled = + self.tabs[i].scene.document.header.dimension_associativity == 2; let committed = self.commit_entity_handle(entity); - if is_linear_dimension { + if is_associative_dimension && association_enabled { if let Some(handle) = committed { let sources = self.tabs[i] .scene - .infer_linear_dimension_sources(handle); + .infer_dimension_sources(handle); self.tabs[i] .scene - .attach_linear_dimension_association(handle, sources); + .attach_dimension_association(handle, sources); } } self.tabs[i].dirty = true; @@ -1205,19 +1210,24 @@ impl OpenCADStudio { let label = self.history_label_from_active_cmd(i, "ENTITY"); let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); - let is_linear_dimension = matches!( + let is_associative_dimension = matches!( entity, - acadrust::EntityType::Dimension(acadrust::entities::Dimension::Linear(_)) + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Linear(_) + | acadrust::entities::Dimension::Aligned(_) + ) ); + let association_enabled = + self.tabs[i].scene.document.header.dimension_associativity == 2; let committed = self.commit_entity_handle(entity); - if is_linear_dimension { + if is_associative_dimension && association_enabled { if let Some(handle) = committed { let sources = self.tabs[i] .scene - .infer_linear_dimension_sources(handle); + .infer_dimension_sources(handle); self.tabs[i] .scene - .attach_linear_dimension_association(handle, sources); + .attach_dimension_association(handle, sources); } } self.tabs[i].dirty = true; @@ -1229,25 +1239,62 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } - CmdResult::CommitAssociativeDimension { entity, source } => { - let label = self.history_label_from_active_cmd(i, "DIMLINEAR"); - let pending = self.begin_undo(i, label, 1, false); - if let Some(handle) = self.commit_entity_handle(entity) { - self.tabs[i] - .scene - .attach_linear_dimension_association(handle, [Some(source), Some(source)]); - self.tabs[i].scene.bump_entities(&[ - (handle, crate::scene::ChangeKind::Modified), - (source, crate::scene::ChangeKind::Modified), - ]); - } + CmdResult::CommitDimension { mut entity, source } => { + let label = self.history_label_from_active_cmd(i, "DIMENSION"); + let association_mode = self.tabs[i] + .scene + .document + .header + .dimension_associativity; + let pending = if association_mode == 0 { + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + let pieces = crate::modules::draw::modify::explode::explode_entity( + &entity, + &self.tabs[i].scene.document, + ); + let delta_safe = pieces + .iter() + .all(|piece| self.delta_add_safe(i, piece)); + let pending = self.begin_undo(i, label, pieces.len(), delta_safe); + for piece in pieces { + self.commit_entity(piece); + } + pending + } else { + let delta_safe = self.delta_add_safe(i, &entity); + let pending = self.begin_undo(i, label, 1, delta_safe); + if let Some(handle) = self.commit_entity_handle(entity) { + if association_mode == 2 { + let sources = source.map_or_else( + || self.tabs[i].scene.infer_dimension_sources(handle), + |source| [Some(source), Some(source)], + ); + self.tabs[i] + .scene + .attach_dimension_association(handle, sources); + let mut changes = vec![ + (handle, crate::scene::ChangeKind::Modified), + ]; + changes.extend(sources.into_iter().flatten().map(|source| { + (source, crate::scene::ChangeKind::Modified) + })); + changes.sort_by_key(|(handle, _)| handle.value()); + changes.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changes); + } + } + pending + }; self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); - if let Some(pending) = pending { - self.commit_undo_delta(i, pending); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); } } CmdResult::CommitSolid { diff --git a/src/command.rs b/src/command.rs index 89b8d038..ea235802 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1212,10 +1212,12 @@ pub enum CmdResult { CommitEntitiesAndExit(Vec), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), - /// Commit an object-selected linear dimension and retain its source link. - CommitAssociativeDimension { + /// Commit a linear/aligned dimension while honoring the drawing's + /// association mode. `source` is present for object-selection workflows; + /// point-created dimensions resolve sources from their snapped endpoints. + CommitDimension { entity: EntityType, - source: Handle, + source: Option, }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index a3768011..f88b593d 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -193,10 +193,9 @@ impl CadCommand for LinearDimensionCommand { let entity = self.plane.place_entity(EntityType::Dimension( Dimension::Linear(dim), )); - if let Some(source) = self.source_handle { - CmdResult::CommitAssociativeDimension { entity, source } - } else { - CmdResult::CommitAndExit(entity) + CmdResult::CommitDimension { + entity, + source: self.source_handle, } } } @@ -366,11 +365,42 @@ fn dimension_line_offset(second: DVec3, point: DVec3, axis: DVec3) -> f64 { (point - second).dot(perpendicular) } -fn dimension_source_points(entity: &EntityType, click: DVec3) -> Option<(DVec3, DVec3)> { +pub(crate) fn dimension_source_points( + entity: &EntityType, + click: DVec3, +) -> Option<(DVec3, DVec3)> { let point = |p: Vector3| DVec3::new(p.x, p.y, p.z); match entity { EntityType::Line(line) => Some((point(line.start), point(line.end))), EntityType::Arc(arc) => Some((point(arc.start_point_wcs()), point(arc.end_point_wcs()))), + EntityType::Circle(circle) => { + let click = crate::scene::view::transform::wcs_point_to_ocs( + (click.x, click.y, click.z), + (circle.normal.x, circle.normal.y, circle.normal.z), + ); + let delta = DVec3::new( + click.0 - circle.center.x, + click.1 - circle.center.y, + 0.0, + ); + let direction = if delta.length_squared() > 1e-24 { + delta.normalize() + } else { + DVec3::X + }; + let first = [ + circle.center.x + direction.x * circle.radius, + circle.center.y + direction.y * circle.radius, + ]; + let second = [ + circle.center.x - direction.x * circle.radius, + circle.center.y - direction.y * circle.radius, + ]; + Some(( + ocs_point(first, circle.center.z, circle.normal), + ocs_point(second, circle.center.z, circle.normal), + )) + } EntityType::LwPolyline(polyline) => nearest_planar_source( polyline .vertices diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index bda8f168..49d9d70d 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -56,6 +56,9 @@ fn source_points(entity: &EntityType) -> Vec { } fn source_marker(entity: &EntityType, point: Vector3) -> Option { + if matches!(entity, EntityType::Circle(_)) { + return Some(0); + } source_points(entity) .into_iter() .enumerate() @@ -69,11 +72,82 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { let source = *reference.xrefs.first()?; let entity = scene.document.get_entity(source)?; + if let EntityType::Circle(circle) = entity { + let stored = crate::scene::view::transform::wcs_point_to_ocs( + ( + reference.osnap_point.x, + reference.osnap_point.y, + reference.osnap_point.z, + ), + (circle.normal.x, circle.normal.y, circle.normal.z), + ); + let dx = stored.0 - circle.center.x; + let dy = stored.1 - circle.center.y; + let length = dx.hypot(dy); + let stored_angle = dy.atan2(dx); + let angle = if reference.osnap_distance.abs() > 1e-12 + || stored_angle.abs() <= 1e-12 + { + reference.osnap_distance + } else { + stored_angle + }; + let (sin, cos) = angle.sin_cos(); + let (ux, uy) = if angle.is_finite() { + (cos, sin) + } else if length > 1e-12 { + (dx / length, dy / length) + } else { + (1.0, 0.0) + }; + return Some(ocs_point( + circle.center.x + ux * circle.radius, + circle.center.y + uy * circle.radius, + circle.center.z, + circle.normal, + )); + } source_points(entity) .get(reference.main_gs_marker.max(0) as usize) .copied() } +fn dimension_points(dimension: &Dimension) -> Option<[Vector3; 2]> { + match dimension { + Dimension::Linear(linear) => Some([linear.first_point, linear.second_point]), + Dimension::Aligned(aligned) => Some([aligned.first_point, aligned.second_point]), + _ => None, + } +} + +fn source_distance_squared(entity: &EntityType, point: Vector3) -> Option { + if let EntityType::Circle(circle) = entity { + let point = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + (circle.normal.x, circle.normal.y, circle.normal.z), + ); + let radial_error = + (point.0 - circle.center.x).hypot(point.1 - circle.center.y) - circle.radius; + let plane_error = point.2 - circle.center.z; + return Some(radial_error * radial_error + plane_error * plane_error); + } + source_points(entity) + .into_iter() + .map(|candidate| point_distance_squared(candidate, point)) + .min_by(f64::total_cmp) +} + +fn source_parameter(entity: &EntityType, point: Vector3) -> f64 { + let EntityType::Circle(circle) = entity else { + return 0.0; + }; + let point = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + (circle.normal.x, circle.normal.y, circle.normal.z), + ); + (point.1 - circle.center.y).atan2(point.0 - circle.center.x) +} + pub(crate) fn dimension_is_associative( document: &acadrust::CadDocument, dimension: Handle, @@ -97,34 +171,43 @@ pub(crate) fn dimension_is_associative( } impl Scene { - pub(crate) fn attach_linear_dimension_association( + pub(crate) fn attach_dimension_association( &mut self, dimension: Handle, sources: [Option; 2], ) { - let Some(EntityType::Dimension(Dimension::Linear(linear))) = - self.document.get_entity(dimension) + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { return; }; - let first_point = linear.first_point; - let second_point = linear.second_point; - let source_data = [first_point, second_point].map(|point| point); - let resolved: [Option<(Handle, i32)>; 2] = std::array::from_fn(|index| { + let Some(source_data) = dimension_points(entity) else { + return; + }; + let resolved: [Option<(Handle, i32, f64)>; 2] = std::array::from_fn(|index| { let source = sources[index]?; let entity = self.document.get_entity(source)?; - source_marker(entity, source_data[index]).map(|marker| (source, marker)) + source_marker(entity, source_data[index]).map(|marker| { + ( + source, + marker, + source_parameter(entity, source_data[index]), + ) + }) }); if resolved.iter().all(Option::is_none) { return; } - let reference = |source: Handle, marker: i32, point: Vector3| AssocDimensionReference { + let reference = |source: Handle, + marker: i32, + parameter: f64, + point: Vector3| AssocDimensionReference { class_name: "AcDbOsnapPointRef".to_string(), osnap_type: 1, xrefs: vec![source], main_subent_type: 1, main_gs_marker: marker, + osnap_distance: parameter, osnap_point: point, ..AssocDimensionReference::default() }; @@ -132,9 +215,14 @@ impl Scene { std::array::from_fn(|_| Vec::new()); let mut associativity = 0; for (index, resolved) in resolved.into_iter().enumerate() { - if let Some((source, marker)) = resolved { + if let Some((source, marker, parameter)) = resolved { associativity |= 1 << index; - references[index].push(reference(source, marker, source_data[index])); + references[index].push(reference( + source, + marker, + parameter, + source_data[index], + )); } } @@ -165,24 +253,23 @@ impl Scene { } } - pub(crate) fn infer_linear_dimension_sources( + pub(crate) fn infer_dimension_sources( &self, dimension: Handle, ) -> [Option; 2] { - let Some(EntityType::Dimension(Dimension::Linear(linear))) = - self.document.get_entity(dimension) + let Some(EntityType::Dimension(entity)) = self.document.get_entity(dimension) else { return [None, None]; }; - [linear.first_point, linear.second_point].map(|point| { + let Some(points) = dimension_points(entity) else { + return [None, None]; + }; + points.map(|point| { self.document .entities() .filter(|entity| entity.common().handle != dimension) .filter_map(|entity| { - source_points(entity) - .into_iter() - .map(|candidate| point_distance_squared(candidate, point)) - .min_by(f64::total_cmp) + source_distance_squared(entity, point) .map(|distance| (distance, entity.common().handle)) }) .filter(|(distance, _)| *distance <= 1e-16) @@ -231,19 +318,35 @@ impl Scene { if first.is_none() && second.is_none() { continue; } - if let Some(EntityType::Dimension(Dimension::Linear(linear))) = + let Some(EntityType::Dimension(dimension)) = self.document.get_entity_mut(association.dimension) - { - if let Some(first) = first { - linear.first_point = first; + else { + continue; + }; + match dimension { + Dimension::Linear(linear) => { + if let Some(first) = first { + linear.first_point = first; + } + if let Some(second) = second { + linear.second_point = second; + } + linear.base.actual_measurement = linear.measurement(); + linear.base.definition_point = linear.definition_point; } - if let Some(second) = second { - linear.second_point = second; + Dimension::Aligned(aligned) => { + if let Some(first) = first { + aligned.first_point = first; + } + if let Some(second) = second { + aligned.second_point = second; + } + aligned.base.actual_measurement = aligned.measurement(); + aligned.base.definition_point = aligned.definition_point; } - linear.base.actual_measurement = linear.measurement(); - linear.base.definition_point = linear.definition_point; - refreshed.push((association.dimension, ChangeKind::Modified)); + _ => continue, } + refreshed.push((association.dimension, ChangeKind::Modified)); } refreshed } From 713a02f3e03a2ef19ef3aae51fe6f604424b7b34 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:52:34 +0300 Subject: [PATCH 41/98] Complete aligned dimension command flow --- src/modules/annotate/aligned_dim.rs | 145 ++++++++++++++++++++++------ 1 file changed, 118 insertions(+), 27 deletions(-) diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index 81fa8786..9f199a84 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -1,7 +1,7 @@ // DIMALIGNED command — aligned dimension (measures true distance between two points). use acadrust::entities::{Dimension, DimensionAligned}; -use acadrust::types::Vector3; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use glam::DVec3; @@ -38,6 +38,14 @@ pub struct AlignedDimensionCommand { text_angle: Option, /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + /// True while Enter-at-the-first-prompt is waiting for an entity pick. + selecting_object: bool, + /// Entity data injected by the host before `on_entity_pick`. + picked_entity: Option, + /// Source retained when both extension origins came from one entity. + source_handle: Option, + /// Distinguishes the formatted-text option from the plain-text option. + mtext_override: bool, } impl AlignedDimensionCommand { @@ -49,6 +57,10 @@ impl AlignedDimensionCommand { awaiting_text: false, text_angle: None, awaiting_angle: false, + selecting_object: false, + picked_entity: None, + source_handle: None, + mtext_override: false, } } } @@ -64,18 +76,29 @@ impl CadCommand for AlignedDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMALIGNED Enter dimension text (blank = measured value):").into_owned(); + return if self.mtext_override { + t!("DIMALIGNED Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMALIGNED Enter dimension text (blank = measured value):").into_owned() + }; } if self.awaiting_angle { return t!("DIMALIGNED Specify text angle (degrees):").into_owned(); } + if self.selecting_object { + return t!("DIMALIGNED Select object to dimension:").into_owned(); + } match self.step { - Step::First => t!("DIMALIGNED Specify first extension line origin:").into_owned(), + Step::First => t!( + "DIMALIGNED Specify first extension line origin or press Enter to select object:" + ) + .into_owned(), Step::Second(_) => { - t!("DIMALIGNED Specify second extension line origin [Text/Angle]:").into_owned() + t!("DIMALIGNED Specify second extension line origin:").into_owned() } Step::DimLine { .. } => { - t!("DIMALIGNED Specify dimension line location [Text/Angle]:").into_owned() + t!("DIMALIGNED Specify dimension line location [Mtext/Text/Angle]:").into_owned() } } } @@ -87,6 +110,9 @@ impl CadCommand for AlignedDimensionCommand { CmdResult::NeedPoint } Step::Second(p1) => { + if pt.distance_squared(p1) <= 1e-24 { + return CmdResult::NeedPoint; + } self.step = Step::DimLine { p1, p2: pt }; CmdResult::NeedPoint } @@ -110,14 +136,18 @@ impl CadCommand for AlignedDimensionCommand { dim.base.text_middle_point = v3((d1 + d2) * 0.5); dim.base.insertion_point = dim.base.text_middle_point; dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); // An explicit text angle overrides the default rotation. if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Aligned(dim), - ))) + CmdResult::CommitDimension { + entity: self.plane.place_entity(EntityType::Dimension(Dimension::Aligned(dim))), + source: self.source_handle, + } } } } @@ -132,6 +162,10 @@ impl CadCommand for AlignedDimensionCommand { self.awaiting_angle = false; return CmdResult::NeedPoint; } + if matches!(self.step, Step::First) { + self.selecting_object = true; + return CmdResult::NeedPoint; + } CmdResult::Cancel } @@ -173,8 +207,17 @@ impl CadCommand for AlignedDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } + if !matches!(self.step, Step::DimLine { .. }) { + return None; + } match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; self.awaiting_text = true; Some(CmdResult::NeedPoint) } @@ -186,6 +229,38 @@ impl CadCommand for AlignedDimensionCommand { } } + fn needs_entity_pick(&self) -> bool { + self.selecting_object + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some((p1, p2)) = super::linear_dim::dimension_source_points(&entity, point) else { + return CmdResult::NeedPoint; + }; + if p1.distance_squared(p2) <= 1e-24 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.selecting_object = false; + self.step = Step::DimLine { p1, p2 }; + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { let (p1, p2) = match self.step { Step::First => return None, @@ -272,13 +347,15 @@ fn dim_line_endpoints(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> (DVec3, DVec3) { } fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel { - // Show ext lines + dim line. let (d1, d2) = dim_line_endpoints(p1, p2, dim_pt); - // Preview WireModel points are screen/GPU-side: downcast to f32. - let p1 = p1.as_vec3(); - let p2 = p2.as_vec3(); - let d1 = d1.as_vec3(); - let d2 = d2.as_vec3(); + let axis = (d2 - d1).normalize_or_zero(); + let perp = DVec3::new(-axis.y, axis.x, 0.0); + let nan = [f32::NAN, 0.0, 0.0]; + let arrow = 0.22; + let text = (d1 + d2) * 0.5 + perp * 0.15; + let half_width = ((p2 - p1).length().log10().max(0.0) + 1.0) * 0.18; + let half_height = 0.16; + let to_point = |point: DVec3| point.as_vec3().to_array(); WireModel { point_marker: None, taper_widths: Vec::new(), @@ -292,19 +369,33 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimaligned_preview".into(), points: vec![ - [p1.x, p1.y, p1.z], - [d1.x, d1.y, d1.z], - [f32::NAN, 0.0, 0.0], - [p2.x, p2.y, p2.z], - [d2.x, d2.y, d2.z], - [f32::NAN, 0.0, 0.0], - [d1.x, d1.y, d1.z], - [d2.x, d2.y, d2.z], + to_point(p1), + to_point(d1), + nan, + to_point(p2), + to_point(d2), + nan, + to_point(d1), + to_point(d2), + nan, + to_point(d1 + axis * arrow + perp * arrow * 0.45), + to_point(d1), + to_point(d1 + axis * arrow - perp * arrow * 0.45), + nan, + to_point(d2 - axis * arrow + perp * arrow * 0.45), + to_point(d2), + to_point(d2 - axis * arrow - perp * arrow * 0.45), + nan, + to_point(text - axis * half_width - perp * half_height), + to_point(text + axis * half_width - perp * half_height), + to_point(text + axis * half_width + perp * half_height), + to_point(text - axis * half_width + perp * half_height), + to_point(text - axis * half_width - perp * half_height), ], points_low: Vec::new(), color: WireModel::CYAN, From 9b79abc9a3957d143524517f813235946ee22112 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:52:35 +0300 Subject: [PATCH 42/98] Align dimension property behavior --- src/entities/dimension.rs | 53 ++++++++++++++++++++++++++++++++++----- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index ad6cd235..320f3b1f 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -86,8 +86,21 @@ fn base_props(base: &DimensionBase) -> Vec Vec { - if let Dimension::Linear(d) = dim { - let base = &d.base; + let compact_linear = match dim { + Dimension::Linear(d) => Some(( + &d.base, + d.rotation, + d.ext_line_rotation, + )), + Dimension::Aligned(d) => Some(( + &d.base, + (d.second_point.y - d.first_point.y) + .atan2(d.second_point.x - d.first_point.x), + d.ext_line_rotation, + )), + _ => None, + }; + if let Some((base, rotation, ext_line_rotation)) = compact_linear { return vec![PropSection { title: t!("Misc").into_owned(), props: vec![ @@ -98,11 +111,15 @@ fn properties(dim: &Dimension) -> Vec { base.style_name.clone(), ), }, - edit_angle(t!("Dim line angle").as_ref(), "rotation", d.rotation.to_degrees()), + edit_angle( + t!("Dim line angle").as_ref(), + "rotation", + rotation.to_degrees(), + ), edit_angle( t!("Extension line angle").as_ref(), "ext_line_rotation", - d.ext_line_rotation.to_degrees(), + ext_line_rotation.to_degrees(), ), ], }]; @@ -354,6 +371,29 @@ fn apply_geom_prop(dim: &mut Dimension, field: &str, value: &str) { } fn apply_linear_fields_aligned(d: &mut DimensionAligned, field: &str, value: &str) { + if field == "rotation" { + let Some(angle) = parse_f64(value).map(f64::to_radians) else { + return; + }; + let old_angle = (d.second_point.y - d.first_point.y) + .atan2(d.second_point.x - d.first_point.x); + let delta = angle - old_angle; + let origin_x = d.first_point.x; + let origin_y = d.first_point.y; + let rotate = |point: &mut acadrust::types::Vector3| { + let x = point.x - origin_x; + let y = point.y - origin_y; + let (sin, cos) = delta.sin_cos(); + point.x = origin_x + x * cos - y * sin; + point.y = origin_y + x * sin + y * cos; + }; + rotate(&mut d.second_point); + rotate(&mut d.definition_point); + rotate(&mut d.base.definition_point); + rotate(&mut d.base.text_middle_point); + rotate(&mut d.base.insertion_point); + return; + } apply_linear_common( &mut d.first_point, &mut d.second_point, @@ -1495,6 +1535,7 @@ pub fn style_sections( let s = style; let dimfxlon = int(ov::DIMFXLON, s.dimfxlon as i16) != 0; + let dimtix = int(ov::DIMTIX, s.dimtix as i16) != 0; let dimlunit = int(ov::DIMLUNIT, s.dimlunit); let dimfrac = int(ov::DIMFRAC, s.dimfrac); let dimalt = int(ov::DIMALT, s.dimalt as i16) != 0; @@ -1859,7 +1900,7 @@ pub fn style_sections( "dim_text_inside_align", on(int(ov::DIMTIH, s.dimtih as i16) != 0), &["On", "Off"], - true, + dimtix, ), property( t!("Text position X").as_ref(), @@ -1925,7 +1966,7 @@ pub fn style_sections( choice( t!("Text inside").as_ref(), "dim_text_inside", - on(int(ov::DIMTIX, s.dimtix as i16) != 0), + on(dimtix), &["On", "Off"], true, ), From a4ee74925102ebee2100fc38693d264e63cd9bb8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:13:35 +0300 Subject: [PATCH 43/98] Enable tolerance property controls --- src/entities/dimension.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 320f3b1f..04324c69 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -2233,14 +2233,14 @@ pub fn style_sections( "dim_tolerance_pos_vert", tolerance_vertical_label(int(ov::DIMTOLJ, s.dimtolj)), &["Bottom", "Middle", "Top"], - tolerance_enabled, + true, ), choice( t!("Tolerance alignment").as_ref(), "dim_tolerance_alignment", tolerance_alignment_label(int(ov::DIMTALN, 0)), &["Align decimal separators", "Align operational symbols"], - tolerance_display == "Deviation", + true, ), choice( t!("Tolerance suppress leading zeros").as_ref(), From a4d913d77229bd8c7c9578af0945d0e557d3b96e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:13:35 +0300 Subject: [PATCH 44/98] Correct sub-unit scale application --- src/entities/dimension.rs | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 04324c69..7cadfd95 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -4484,16 +4484,20 @@ fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option 1e-12; + // DIMALTMZF replaces the ordinary alternate-unit factor for sub-unit + // values; it is not an additional multiplier. + let mut v = if use_sub_units { + measurement * s.dimaltmzf + } else { + scaled + }; if s.dimaltrnd > 1e-12 { v = (v / s.dimaltrnd).round() * s.dimaltrnd; } - let use_sub_units = s.dimaltz & 4 != 0 - && v.abs() < 1.0 - && s.dimaltmzf.abs() > 1e-12; - if use_sub_units { - v *= s.dimaltmzf; - } let dec = s.dimaltd.max(0) as usize; let raw = format_with_unit(v, s.dimaltu, dec, s.dimfrac, s.dimaltz, true); let suppressed = apply_linear_zero_suppression(&raw, s.dimaltz); @@ -4659,14 +4663,18 @@ fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String { .unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0, 1.0, "")); let lfac = if lfac.abs() < 1e-12 { 1.0 } else { lfac }; - let mut v = measurement * lfac; + let scaled = measurement * lfac; + let use_sub_units = zin & 4 != 0 && scaled.abs() < 1.0 && sub_factor.abs() > 1e-12; + // For values below one unit, DIMMZF replaces DIMLFAC; applying it after + // DIMLFAC multiplies both factors and reports the wrong sub-unit value. + let mut v = if use_sub_units { + measurement * sub_factor + } else { + scaled + }; if rnd > 1e-12 { v = (v / rnd).round() * rnd; } - let use_sub_units = zin & 4 != 0 && v.abs() < 1.0 && sub_factor.abs() > 1e-12; - if use_sub_units { - v *= sub_factor; - } let dec = dec.max(0) as usize; let raw = format_with_unit(v, lunit, dec, frac, zin, false); let suppressed = apply_linear_zero_suppression(&raw, zin); From 5de38e11244f70a51471e9a257a19dd04c7f99ad Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:37:56 +0300 Subject: [PATCH 45/98] Correct plot style mode handling --- src/app/properties.rs | 40 ++++++++++++++++++--------------------- src/app/update/command.rs | 10 +++++++++- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index e4e639c2..417f8d43 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -203,11 +203,15 @@ impl OpenCADStudio { }) .unwrap_or_else(|| "ByLayer".to_string()) }; - let plot_style = match header.current_plotstyle_type { - 1 => "ByBlock", - 2 => "ByColor", - 3 => "ByObject", - _ => "ByLayer", + let plot_style = if header.plotstyle_mode { + "ByColor" + } else { + match header.current_plotstyle_type { + 1 => "ByBlock", + 2 => "Normal", + 3 => "ByObject", + _ => "ByLayer", + } }; let layout_plot_table = doc.objects.values().find_map(|object| { let acadrust::objects::ObjectType::Layout(layout) = object else { @@ -316,7 +320,7 @@ impl OpenCADStudio { Property { label: t!("Plot style").into_owned(), field: "plot_style", - value: if header.plotstyle_mode { + value: if !header.plotstyle_mode { PropValue::Choice { selected: plot_style.to_string(), options: vec![ @@ -327,7 +331,7 @@ impl OpenCADStudio { } } else { PropValue::ReadOnlyWithTooltip { - value: plot_style.to_string(), + value: "ByColor".to_string(), tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), } }, @@ -344,9 +348,9 @@ impl OpenCADStudio { read_only( t!("Plot table type").as_ref(), if header.plotstyle_mode { - "Named plot styles".to_string() - } else { "Color-dependent plot styles".to_string() + } else { + "Named plot styles".to_string() }, ), ], @@ -746,11 +750,10 @@ impl OpenCADStudio { } } - // In named plot-style mode (PSTYLEMODE=1) the Plot style row - // picks a named style from the drawing's plot style table. In - // the color-dependent mode it stays read-only (the object's - // color drives the plot style), so it is left untouched. - if self.tabs[i].scene.document.header.plotstyle_mode { + // Named plot-style mode exposes the drawing's styles. In + // color-dependent mode the object's color determines the + // style, so the row is locked to ByColor. + if !self.tabs[i].scene.document.header.plotstyle_mode { let doc = &self.tabs[i].scene.document; let dict_h = doc.header.acad_plotstylename_dict_handle; let common = entity.common(); @@ -797,15 +800,8 @@ impl OpenCADStudio { if let Some(row) = section.props.iter_mut().find(|p| p.field == "plot_style") { - let common = entity.common(); - let plot_style_str = match common.plotstyle_flags { - 0 => "ByLayer", - 1 => "ByBlock", - 2 => "Normal", - _ => "ByColor", - }; row.value = crate::scene::model::object::PropValue::ReadOnlyWithTooltip { - value: plot_style_str.to_string(), + value: "ByColor".to_string(), tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), }; } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index ab25667d..05ce1d1b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2169,6 +2169,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } else if field == "plot_style" { + if self.tabs[i].scene.document.header.plotstyle_mode { + self.refresh_properties(); + return Task::none(); + } // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's // ACAD_PLOTSTYLENAME dictionary to its placeholder handle. @@ -2349,11 +2353,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.refresh_properties(); } "plot_style" => { + if self.tabs[i].scene.document.header.plotstyle_mode { + self.refresh_properties(); + return Task::none(); + } match value.as_str() { "ByBlock" => { self.tabs[i].scene.document.header.current_plotstyle_type = 1 } - "Normal" | "ByColor" => { + "Normal" => { self.tabs[i].scene.document.header.current_plotstyle_type = 2 } _ => self.tabs[i].scene.document.header.current_plotstyle_type = 0, From e93815d0c52329c3a04dbfa675c75d2a2759b230 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:29:38 +0300 Subject: [PATCH 46/98] Complete angular dimension creation and associations --- src/app/command_driver.rs | 21 +- src/command.rs | 4 +- src/modules/annotate/angular_dim.rs | 691 +++++++++++++++++++++++----- src/modules/annotate/linear_dim.rs | 5 +- src/scene/dimension_assoc.rs | 171 +++++-- 5 files changed, 744 insertions(+), 148 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bf03b591..1bff0d72 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1229,17 +1229,24 @@ impl OpenCADStudio { self.commit_undo_delta(i, pd); } } - CmdResult::CommitAssociativeDimension { entity, source } => { - let label = self.history_label_from_active_cmd(i, "DIMLINEAR"); + CmdResult::CommitAssociativeDimension { entity, sources } => { + let label = self.history_label_from_active_cmd(i, "DIMENSION"); let pending = self.begin_undo(i, label, 1, false); if let Some(handle) = self.commit_entity_handle(entity) { self.tabs[i] .scene - .attach_linear_dimension_association(handle, [Some(source), Some(source)]); - self.tabs[i].scene.bump_entities(&[ - (handle, crate::scene::ChangeKind::Modified), - (source, crate::scene::ChangeKind::Modified), - ]); + .attach_dimension_association( + handle, + sources.iter().copied().map(Some).collect(), + ); + let mut changed = vec![(handle, crate::scene::ChangeKind::Modified)]; + changed.extend( + sources + .iter() + .copied() + .map(|source| (source, crate::scene::ChangeKind::Modified)), + ); + self.tabs[i].scene.bump_entities(&changed); } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/command.rs b/src/command.rs index 89b8d038..b8dbdb6f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1212,10 +1212,10 @@ pub enum CmdResult { CommitEntitiesAndExit(Vec), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), - /// Commit an object-selected linear dimension and retain its source link. + /// Commit an object-selected dimension and retain its source links. CommitAssociativeDimension { entity: EntityType, - source: Handle, + sources: Vec, }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index 36a917e4..d6e9edf9 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -1,12 +1,12 @@ -use acadrust::entities::{Dimension, DimensionAngular3Pt}; -use acadrust::types::Vector3; +use acadrust::entities::{Dimension, DimensionAngular2Ln, DimensionAngular3Pt}; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::{DVec3, Vec3}; use crate::t; +use glam::DVec3; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_angular.svg")); @@ -22,20 +22,55 @@ pub fn tool() -> ToolDef { enum Step { Vertex, FirstRay(DVec3), - SecondRay { + SecondRay { vertex: DVec3, first: DVec3 }, + CircleSecondRay { vertex: DVec3, first: DVec3, + radius: f64, + source: Handle, }, - ArcPoint { + SecondLine { + first_start: DVec3, + first_end: DVec3, + first_source: Handle, + }, + ArcPoint3 { vertex: DVec3, first: DVec3, second: DVec3, }, + ArcPoint2 { + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, + }, +} + +enum PickedCurve { + Line(DVec3, DVec3), + Arc { + center: DVec3, + first: DVec3, + second: DVec3, + }, + Circle { + center: DVec3, + radius: f64, + }, } pub struct AngularDimensionCommand { step: Step, plane: WorkingPlane, + selecting_object: bool, + picked_entity: Option, + source_handles: Vec, + text_override: Option, + awaiting_text: bool, + mtext_override: bool, + text_angle: Option, + awaiting_angle: bool, } impl AngularDimensionCommand { @@ -43,8 +78,92 @@ impl AngularDimensionCommand { Self { step: Step::Vertex, plane: WorkingPlane::default(), + selecting_object: false, + picked_entity: None, + source_handles: Vec::new(), + text_override: None, + awaiting_text: false, + mtext_override: false, + text_angle: None, + awaiting_angle: false, } } + + fn finish_three_point( + &self, + vertex: DVec3, + first: DVec3, + second: DVec3, + arc_point: DVec3, + ) -> CmdResult { + let vertex = self.plane.to_local(vertex); + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + let arc_point = self.plane.to_local(arc_point); + let mut dim = DimensionAngular3Pt::new(v3(vertex), v3(first), v3(second)); + dim.definition_point = v3(arc_point); + dim.base.definition_point = dim.definition_point; + dim.base.text_middle_point = dim.definition_point; + dim.base.insertion_point = dim.definition_point; + dim.base.actual_measurement = dim.measurement_degrees(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); + if let Some(angle) = self.text_angle { + dim.base.text_rotation = angle; + } + self.commit(Dimension::Angular3Pt(dim)) + } + + fn finish_two_line( + &self, + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, + arc_point: DVec3, + ) -> CmdResult { + let first_start = self.plane.to_local(first_start); + let first_end = self.plane.to_local(first_end); + let second_start = self.plane.to_local(second_start); + let second_end = self.plane.to_local(second_end); + let arc_point = self.plane.to_local(arc_point); + let mut dim = DimensionAngular2Ln::default(); + dim.first_point = v3(first_start); + dim.second_point = v3(first_end); + dim.angle_vertex = v3(second_start); + dim.definition_point = v3(second_end); + dim.dimension_arc = v3(arc_point); + dim.base.definition_point = dim.dimension_arc; + dim.base.text_middle_point = dim.dimension_arc; + dim.base.insertion_point = dim.dimension_arc; + dim.base.actual_measurement = dim.measurement_degrees(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); + if let Some(angle) = self.text_angle { + dim.base.text_rotation = angle; + } + self.commit(Dimension::Angular2Ln(dim)) + } + + fn commit(&self, dimension: Dimension) -> CmdResult { + let entity = self.plane.place_entity(EntityType::Dimension(dimension)); + if self.source_handles.is_empty() { + CmdResult::CommitAndExit(entity) + } else { + CmdResult::CommitAssociativeDimension { + entity, + sources: self.source_handles.clone(), + } + } + } + + fn placement_step(&self) -> bool { + matches!(self.step, Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. }) + } } impl CadCommand for AngularDimensionCommand { @@ -57,59 +176,102 @@ impl CadCommand for AngularDimensionCommand { } fn prompt(&self) -> String { + if self.awaiting_text { + return if self.mtext_override { + t!("DIMANGULAR Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMANGULAR Enter dimension text (blank = measured value):").into_owned() + }; + } + if self.awaiting_angle { + return t!("DIMANGULAR Specify text angle (degrees):").into_owned(); + } + if self.selecting_object { + return match self.step { + Step::SecondLine { .. } => t!("DIMANGULAR Select second line:").into_owned(), + _ => t!("DIMANGULAR Select arc, circle, line, or polyline arc:").into_owned(), + }; + } match self.step { - Step::Vertex => t!("DIMANGULAR Specify angle vertex:").into_owned(), + Step::Vertex => t!( + "DIMANGULAR Specify angle vertex or press Enter to select an object:" + ) + .into_owned(), Step::FirstRay(_) => { t!("DIMANGULAR Specify first extension line point:").into_owned() } - Step::SecondRay { .. } => { + Step::SecondRay { .. } | Step::CircleSecondRay { .. } => { t!("DIMANGULAR Specify second extension line point:").into_owned() } - Step::ArcPoint { .. } => t!("DIMANGULAR Specify dimension arc location:").into_owned(), + Step::SecondLine { .. } => t!("DIMANGULAR Select second line:").into_owned(), + Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. } => t!( + "DIMANGULAR Specify dimension arc location [Mtext/Text/Angle/Quadrant]:" + ) + .into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { + fn on_point(&mut self, point: DVec3) -> CmdResult { match self.step { Step::Vertex => { - self.step = Step::FirstRay(pt); + self.step = Step::FirstRay(point); CmdResult::NeedPoint } Step::FirstRay(vertex) => { - self.step = Step::SecondRay { vertex, first: pt }; + if point.distance_squared(vertex) <= 1.0e-24 { + return CmdResult::NeedPoint; + } + self.step = Step::SecondRay { vertex, first: point }; CmdResult::NeedPoint } Step::SecondRay { vertex, first } => { - self.step = Step::ArcPoint { - vertex, - first, - second: pt, - }; + if point.distance_squared(vertex) <= 1.0e-24 { + return CmdResult::NeedPoint; + } + self.step = Step::ArcPoint3 { vertex, first, second: point }; CmdResult::NeedPoint } - Step::ArcPoint { - vertex, - first, - second, - } => { - let vertex = self.plane.to_local(vertex); - let first = self.plane.to_local(first); - let second = self.plane.to_local(second); - let pt = self.plane.to_local(pt); - let mut dim = DimensionAngular3Pt::new(v3(vertex), v3(first), v3(second)); - dim.definition_point = v3(pt); - dim.base.definition_point = v3(pt); - dim.base.text_middle_point = v3(pt); - dim.base.insertion_point = v3(pt); - dim.base.actual_measurement = dim.measurement_degrees(); - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Angular3Pt(dim), - ))) + Step::CircleSecondRay { vertex, first, radius, source } => { + let Some(second) = project_to_radius(vertex, point, radius) else { + return CmdResult::NeedPoint; + }; + self.source_handles = vec![source, source, source]; + self.step = Step::ArcPoint3 { vertex, first, second }; + CmdResult::NeedPoint } + Step::SecondLine { .. } => CmdResult::NeedPoint, + Step::ArcPoint3 { vertex, first, second } => { + self.finish_three_point(vertex, first, second, point) + } + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => self.finish_two_line( + first_start, + first_end, + second_start, + second_end, + point, + ), } } fn on_enter(&mut self) -> CmdResult { + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + return CmdResult::NeedPoint; + } + if matches!(self.step, Step::Vertex) { + self.selecting_object = true; + return CmdResult::NeedPoint; + } CmdResult::Cancel } @@ -117,49 +279,392 @@ impl CadCommand for AngularDimensionCommand { CmdResult::Cancel } - fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); - match self.step { - Step::Vertex => None, - Step::FirstRay(vertex) => Some(preview_wire(vec![vertex.as_vec3(), pt])), - Step::SecondRay { vertex, first } => Some(preview_wire(vec![ - vertex.as_vec3(), - first.as_vec3(), - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - vertex.as_vec3(), - pt, - ])), - Step::ArcPoint { - vertex, - first, - second, - } => { - let points = angular_preview( - self.plane.to_local(vertex).as_vec3(), - self.plane.to_local(first).as_vec3(), - self.plane.to_local(second).as_vec3(), - self.plane.to_local(pt.as_dvec3()).as_vec3(), - ) - .into_iter() - .map(|point| { - if point.x.is_nan() { - point - } else { - self.plane.to_world(point.as_dvec3()).as_vec3() - } - }) - .collect(); - Some(preview_wire(points)) - } + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + self.awaiting_text + } + + fn point_step_accepts_keywords(&self) -> bool { + self.placement_step() && !self.awaiting_text && !self.awaiting_angle + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_text { + let value = text.trim(); + self.text_override = if value.is_empty() || value == "<>" { + None + } else { + Some(value.to_string()) + }; + self.awaiting_text = false; + return Some(CmdResult::NeedPoint); } + if self.awaiting_angle { + self.text_angle = if text.trim().is_empty() { + None + } else { + crate::entities::common::parse_typed_angle(text.trim()) + }; + self.awaiting_angle = false; + return Some(CmdResult::NeedPoint); + } + if !self.placement_step() { + return None; + } + match text.trim().to_ascii_uppercase().as_str() { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "A" | "ANGLE" => { + self.awaiting_angle = true; + Some(CmdResult::NeedPoint) + } + "Q" | "QUADRANT" => Some(CmdResult::NeedPoint), + _ => None, + } + } + + fn needs_entity_pick(&self) -> bool { + self.selecting_object + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(curve) = picked_curve(&entity, point) else { + return CmdResult::NeedPoint; + }; + + match (&self.step, curve) { + ( + Step::SecondLine { first_start, first_end, first_source }, + PickedCurve::Line(second_start, second_end), + ) if *first_source != handle => { + self.source_handles = vec![*first_source, *first_source, handle, handle]; + self.selecting_object = false; + self.step = Step::ArcPoint2 { + first_start: *first_start, + first_end: *first_end, + second_start, + second_end, + }; + CmdResult::NeedPoint + } + (Step::Vertex, PickedCurve::Line(first_start, first_end)) => { + self.step = Step::SecondLine { + first_start, + first_end, + first_source: handle, + }; + CmdResult::NeedPoint + } + (Step::Vertex, PickedCurve::Arc { center, first, second }) => { + self.source_handles = vec![handle, handle, handle]; + self.selecting_object = false; + self.step = Step::ArcPoint3 { vertex: center, first, second }; + CmdResult::NeedPoint + } + (Step::Vertex, PickedCurve::Circle { center, radius }) => { + let Some(first) = project_to_radius(center, point, radius) else { + return CmdResult::NeedPoint; + }; + self.selecting_object = false; + self.step = Step::CircleSecondRay { + vertex: center, + first, + radius, + source: handle, + }; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + } + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + let points = match self.step { + Step::Vertex | Step::SecondLine { .. } => return None, + Step::FirstRay(vertex) => vec![vertex, point], + Step::SecondRay { vertex, first } => vec![vertex, first, nan(), vertex, point], + Step::CircleSecondRay { vertex, first, radius, .. } => { + let second = project_to_radius(vertex, point, radius).unwrap_or(point); + vec![vertex, first, nan(), vertex, second] + } + Step::ArcPoint3 { vertex, first, second } => { + angular_preview(vertex, first, second, point) + } + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => two_line_preview( + first_start, + first_end, + second_start, + second_end, + point, + ), + }; + Some(preview_wire(points)) } } -fn v3(pt: DVec3) -> Vector3 { - Vector3::new(pt.x, pt.y, pt.z) +fn picked_curve(entity: &EntityType, click: DVec3) -> Option { + let point = |value: Vector3| DVec3::new(value.x, value.y, value.z); + match entity { + EntityType::Line(line) => Some(PickedCurve::Line(point(line.start), point(line.end))), + EntityType::Arc(arc) => Some(PickedCurve::Arc { + center: point(arc.center_wcs()), + first: point(arc.start_point_wcs()), + second: point(arc.end_point_wcs()), + }), + EntityType::Circle(circle) => Some(PickedCurve::Circle { + center: point(circle.center_wcs()), + radius: circle.radius, + }), + EntityType::LwPolyline(polyline) => { + let click = crate::scene::view::transform::wcs_point_to_ocs( + (click.x, click.y, click.z), + (polyline.normal.x, polyline.normal.y, polyline.normal.z), + ); + let segment = nearest_segment_index( + polyline.vertices.iter().map(|vertex| [vertex.location.x, vertex.location.y]).collect(), + polyline.is_closed, + [click.0, click.1], + )?; + let first = polyline.vertices[segment]; + let second = polyline.vertices[(segment + 1) % polyline.vertices.len()]; + polyline_segment( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + ) + } + EntityType::Polyline2D(polyline) => { + let vertices = crate::entities::polyline::drawn_vertices2d(polyline) + .unwrap_or_else(|| polyline.vertices.clone()); + let click = crate::scene::view::transform::wcs_point_to_ocs( + (click.x, click.y, click.z), + (polyline.normal.x, polyline.normal.y, polyline.normal.z), + ); + let segment = nearest_segment_index( + vertices.iter().map(|vertex| [vertex.location.x, vertex.location.y]).collect(), + polyline.is_closed(), + [click.0, click.1], + )?; + let first = &vertices[segment]; + let second = &vertices[(segment + 1) % vertices.len()]; + polyline_segment( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + ) + } + _ => None, + } } -fn preview_wire(points: Vec) -> WireModel { +fn nearest_segment_index(points: Vec<[f64; 2]>, closed: bool, click: [f64; 2]) -> Option { + if points.len() < 2 { + return None; + } + let count = if closed { points.len() } else { points.len() - 1 }; + (0..count).min_by(|first, second| { + segment_distance_squared(points[*first], points[(*first + 1) % points.len()], click) + .total_cmp(&segment_distance_squared( + points[*second], + points[(*second + 1) % points.len()], + click, + )) + }) +} + +fn segment_distance_squared(first: [f64; 2], second: [f64; 2], point: [f64; 2]) -> f64 { + let dx = second[0] - first[0]; + let dy = second[1] - first[1]; + let length_squared = dx * dx + dy * dy; + if length_squared <= 1.0e-24 { + return (point[0] - first[0]).powi(2) + (point[1] - first[1]).powi(2); + } + let t = (((point[0] - first[0]) * dx + (point[1] - first[1]) * dy) / length_squared) + .clamp(0.0, 1.0); + let x = first[0] + dx * t; + let y = first[1] + dy * t; + (point[0] - x).powi(2) + (point[1] - y).powi(2) +} + +fn polyline_segment( + first: [f64; 2], + second: [f64; 2], + bulge: f64, + elevation: f64, + normal: Vector3, +) -> Option { + let to_world = |point: [f64; 2]| { + let value = crate::scene::view::transform::ocs_point_to_wcs( + (point[0], point[1], elevation), + (normal.x, normal.y, normal.z), + ); + DVec3::new(value.0, value.1, value.2) + }; + let first_world = to_world(first); + let second_world = to_world(second); + if bulge.abs() <= 1.0e-12 { + return Some(PickedCurve::Line(first_world, second_world)); + } + let dx = second[0] - first[0]; + let dy = second[1] - first[1]; + let chord = (dx * dx + dy * dy).sqrt(); + if chord <= 1.0e-12 { + return None; + } + let midpoint = [(first[0] + second[0]) * 0.5, (first[1] + second[1]) * 0.5]; + let center_offset = chord * (1.0 - bulge * bulge) / (4.0 * bulge); + let center = [ + midpoint[0] - dy / chord * center_offset, + midpoint[1] + dx / chord * center_offset, + ]; + Some(PickedCurve::Arc { + center: to_world(center), + first: first_world, + second: second_world, + }) +} + +fn project_to_radius(center: DVec3, point: DVec3, radius: f64) -> Option { + let direction = (point - center).normalize_or_zero(); + (direction.length_squared() > 0.0).then_some(center + direction * radius) +} + +fn two_line_frame( + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, + arc_point: DVec3, +) -> Option<(DVec3, f64, f64)> { + let first_direction = first_end - first_start; + let second_direction = second_end - second_start; + let denominator = first_direction.x * second_direction.y - first_direction.y * second_direction.x; + if denominator.abs() + <= 1.0e-12 * first_direction.length().max(second_direction.length()).max(1.0) + { + return None; + } + let offset = second_start - first_start; + let t = (offset.x * second_direction.y - offset.y * second_direction.x) / denominator; + let vertex = first_start + first_direction * t; + let target = (arc_point.y - vertex.y).atan2(arc_point.x - vertex.x); + let mut best: Option<(f64, f64, f64)> = None; + for first_sign in [1.0, -1.0] { + for second_sign in [1.0, -1.0] { + let first = first_direction * first_sign; + let second = second_direction * second_sign; + for (start, end) in [ + (first.y.atan2(first.x), second.y.atan2(second.x)), + (second.y.atan2(second.x), first.y.atan2(first.x)), + ] { + let sweep = (end - start).rem_euclid(std::f64::consts::TAU); + if sweep <= 1.0e-9 || sweep > std::f64::consts::PI { + continue; + } + let selected = (target - start).rem_euclid(std::f64::consts::TAU); + if selected <= sweep + 1.0e-9 && best.is_none_or(|known| sweep < known.2) { + best = Some((start, start + sweep, sweep)); + } + } + } + } + best.map(|(start, end, _)| (vertex, start, end)) +} + +fn angular_preview(vertex: DVec3, first: DVec3, second: DVec3, arc_point: DVec3) -> Vec { + let Some((_, start, end)) = two_line_frame(vertex, first, vertex, second, arc_point) else { + return vec![vertex, first, nan(), vertex, second]; + }; + angular_preview_with_frame(vertex, first, second, arc_point, start, end) +} + +fn two_line_preview( + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, + arc_point: DVec3, +) -> Vec { + let Some((vertex, start, end)) = two_line_frame( + first_start, + first_end, + second_start, + second_end, + arc_point, + ) else { + return vec![first_start, first_end, nan(), second_start, second_end]; + }; + angular_preview_with_frame(vertex, first_start, second_start, arc_point, start, end) +} + +fn angular_preview_with_frame( + vertex: DVec3, + first: DVec3, + second: DVec3, + arc_point: DVec3, + start: f64, + end: f64, +) -> Vec { + let radius = vertex.distance(arc_point); + let first_end = vertex + DVec3::new(start.cos(), start.sin(), 0.0) * radius; + let second_end = vertex + DVec3::new(end.cos(), end.sin(), 0.0) * radius; + let mut points = vec![first, first_end, nan(), second, second_end, nan()]; + let sweep = end - start; + let steps = ((sweep.abs() / std::f64::consts::PI * 90.0).ceil() as usize).clamp(8, 720); + for index in 0..=steps { + let angle = start + sweep * index as f64 / steps as f64; + points.push(vertex + DVec3::new(angle.cos() * radius, angle.sin() * radius, 0.0)); + } + points +} + +fn v3(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +fn nan() -> DVec3 { + DVec3::splat(f64::NAN) +} + +fn preview_wire(points: Vec) -> WireModel { WireModel { point_marker: None, taper_widths: Vec::new(), @@ -173,11 +678,11 @@ fn preview_wire(points: Vec) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimangular_preview".to_string(), - points: points.into_iter().map(|p| [p.x, p.y, p.z]).collect(), + points: points.into_iter().map(|point| [point.x as f32, point.y as f32, point.z as f32]).collect(), points_low: Vec::new(), color: WireModel::CYAN, selected: false, @@ -195,38 +700,4 @@ fn preview_wire(points: Vec) -> WireModel { } } -fn angular_preview(vertex: Vec3, first: Vec3, second: Vec3, arc_pt: Vec3) -> Vec { - let mut points = vec![ - vertex, - first, - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - vertex, - second, - Vec3::new(f32::NAN, f32::NAN, f32::NAN), - ]; - let r = vertex.distance(arc_pt); - if r <= 1e-6 { - return points; - } - let a0 = (first.y - vertex.y).atan2(first.x - vertex.x); - let mut a1 = (second.y - vertex.y).atan2(second.x - vertex.x); - let mut delta = a1 - a0; - while delta <= 0.0 { - delta += std::f32::consts::TAU; - } - if delta > std::f32::consts::PI { - a1 -= std::f32::consts::TAU; - delta = a1 - a0; - } - let steps = 24; - for i in 0..=steps { - let t = i as f32 / steps as f32; - let a = a0 + delta * t; - points.push(vertex + Vec3::new(a.cos() * r, a.sin() * r, 0.0)); - } - points -} - - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] }); // AngularDimensionCommand +inventory::submit!(crate::command::CommandRegistration { names: &["DIMANGULAR"] }); diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index a3768011..4f045844 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -194,7 +194,10 @@ impl CadCommand for LinearDimensionCommand { Dimension::Linear(dim), )); if let Some(source) = self.source_handle { - CmdResult::CommitAssociativeDimension { entity, source } + CmdResult::CommitAssociativeDimension { + entity, + sources: vec![source, source], + } } else { CmdResult::CommitAndExit(entity) } diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index bda8f168..853d4009 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -27,6 +27,7 @@ fn source_points(entity: &EntityType) -> Vec { match entity { EntityType::Line(line) => vec![line.start, line.end], EntityType::Arc(arc) => vec![arc.start_point_wcs(), arc.end_point_wcs()], + EntityType::Circle(_) => Vec::new(), EntityType::LwPolyline(polyline) => polyline .vertices .iter() @@ -55,6 +56,37 @@ fn source_points(entity: &EntityType) -> Vec { } } +fn source_reference(entity: &EntityType, point: Vector3) -> Option<(i32, f64)> { + let curve_parameter = match entity { + EntityType::Circle(circle) => { + let center = circle.center_wcs(); + if point_distance_squared(center, point) <= 1e-16 { + return Some((-3, 0.0)); + } + let (axis_x, axis_y) = circle.axes_wcs(); + let offset = point - center; + Some(offset.dot(&axis_y).atan2(offset.dot(&axis_x))) + } + EntityType::Arc(arc) => { + let local = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + (arc.normal.x, arc.normal.y, arc.normal.z), + ); + let dx = local.0 - arc.center.x; + let dy = local.1 - arc.center.y; + if dx * dx + dy * dy <= 1e-16 { + return Some((-3, 0.0)); + } + Some(dy.atan2(dx)) + } + _ => None, + }; + if let Some(parameter) = curve_parameter { + return Some((-2, parameter)); + } + source_marker(entity, point).map(|marker| (marker, 0.0)) +} + fn source_marker(entity: &EntityType, point: Vector3) -> Option { source_points(entity) .into_iter() @@ -69,11 +101,45 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { let source = *reference.xrefs.first()?; let entity = scene.document.get_entity(source)?; + if reference.main_gs_marker == -3 { + return match entity { + EntityType::Circle(circle) => Some(circle.center_wcs()), + EntityType::Arc(arc) => Some(arc.center_wcs()), + _ => None, + }; + } + if reference.main_gs_marker == -2 { + return match entity { + EntityType::Circle(circle) => { + Some(circle.point_at_angle_wcs(reference.osnap_distance)) + } + EntityType::Arc(arc) => Some(arc.point_at_angle_wcs(reference.osnap_distance)), + _ => None, + }; + } source_points(entity) .get(reference.main_gs_marker.max(0) as usize) .copied() } +fn dimension_reference_points(dimension: &Dimension) -> Vec { + match dimension { + Dimension::Linear(linear) => vec![linear.first_point, linear.second_point], + Dimension::Angular3Pt(angular) => vec![ + angular.angle_vertex, + angular.first_point, + angular.second_point, + ], + Dimension::Angular2Ln(angular) => vec![ + angular.first_point, + angular.second_point, + angular.angle_vertex, + angular.definition_point, + ], + _ => Vec::new(), + } +} + pub(crate) fn dimension_is_associative( document: &acadrust::CadDocument, dimension: Handle, @@ -97,34 +163,41 @@ pub(crate) fn dimension_is_associative( } impl Scene { - pub(crate) fn attach_linear_dimension_association( + pub(crate) fn attach_dimension_association( &mut self, dimension: Handle, - sources: [Option; 2], + sources: Vec>, ) { - let Some(EntityType::Dimension(Dimension::Linear(linear))) = + let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension) else { return; }; - let first_point = linear.first_point; - let second_point = linear.second_point; - let source_data = [first_point, second_point].map(|point| point); - let resolved: [Option<(Handle, i32)>; 2] = std::array::from_fn(|index| { - let source = sources[index]?; + let source_data = dimension_reference_points(dimension_entity); + if source_data.is_empty() { + return; + } + let resolved: Vec> = source_data + .iter() + .enumerate() + .map(|(index, point)| { + let source = sources.get(index).copied().flatten()?; let entity = self.document.get_entity(source)?; - source_marker(entity, source_data[index]).map(|marker| (source, marker)) - }); + source_reference(entity, *point) + .map(|(marker, parameter)| (source, marker, parameter)) + }) + .collect(); if resolved.iter().all(Option::is_none) { return; } - let reference = |source: Handle, marker: i32, point: Vector3| AssocDimensionReference { + let reference = |source: Handle, marker: i32, parameter: f64, point: Vector3| AssocDimensionReference { class_name: "AcDbOsnapPointRef".to_string(), osnap_type: 1, xrefs: vec![source], main_subent_type: 1, main_gs_marker: marker, + osnap_distance: parameter, osnap_point: point, ..AssocDimensionReference::default() }; @@ -132,9 +205,12 @@ impl Scene { std::array::from_fn(|_| Vec::new()); let mut associativity = 0; for (index, resolved) in resolved.into_iter().enumerate() { - if let Some((source, marker)) = resolved { + if index >= references.len() { + break; + } + if let Some((source, marker, parameter)) = resolved { associativity |= 1 << index; - references[index].push(reference(source, marker, source_data[index])); + references[index].push(reference(source, marker, parameter, source_data[index])); } } @@ -165,6 +241,14 @@ impl Scene { } } + pub(crate) fn attach_linear_dimension_association( + &mut self, + dimension: Handle, + sources: [Option; 2], + ) { + self.attach_dimension_association(dimension, sources.into_iter().collect()); + } + pub(crate) fn infer_linear_dimension_sources( &self, dimension: Handle, @@ -222,26 +306,57 @@ impl Scene { let mut refreshed = Vec::new(); for association in associations { - let first = association.references[0] - .first() - .and_then(|reference| resolve_reference(self, reference)); - let second = association.references[1] - .first() - .and_then(|reference| resolve_reference(self, reference)); - if first.is_none() && second.is_none() { + let resolved: [Option; 4] = std::array::from_fn(|index| { + association.references[index] + .first() + .and_then(|reference| resolve_reference(self, reference)) + }); + if resolved.iter().all(Option::is_none) { continue; } - if let Some(EntityType::Dimension(Dimension::Linear(linear))) = + if let Some(EntityType::Dimension(dimension)) = self.document.get_entity_mut(association.dimension) { - if let Some(first) = first { - linear.first_point = first; + match dimension { + Dimension::Linear(linear) => { + if let Some(point) = resolved[0] { + linear.first_point = point; + } + if let Some(point) = resolved[1] { + linear.second_point = point; + } + linear.base.definition_point = linear.definition_point; + } + Dimension::Angular3Pt(angular) => { + if let Some(point) = resolved[0] { + angular.angle_vertex = point; + } + if let Some(point) = resolved[1] { + angular.first_point = point; + } + if let Some(point) = resolved[2] { + angular.second_point = point; + } + angular.base.definition_point = angular.definition_point; + } + Dimension::Angular2Ln(angular) => { + if let Some(point) = resolved[0] { + angular.first_point = point; + } + if let Some(point) = resolved[1] { + angular.second_point = point; + } + if let Some(point) = resolved[2] { + angular.angle_vertex = point; + } + if let Some(point) = resolved[3] { + angular.definition_point = point; + } + angular.base.definition_point = angular.dimension_arc; + } + _ => continue, } - if let Some(second) = second { - linear.second_point = second; - } - linear.base.actual_measurement = linear.measurement(); - linear.base.definition_point = linear.definition_point; + dimension.base_mut().actual_measurement = dimension.measurement(); refreshed.push((association.dimension, ChangeKind::Modified)); } } From 04d824974d7c296abe19832054d25d05c627aa4e Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:29:39 +0300 Subject: [PATCH 47/98] Align angular dimension properties and rendering --- src/app/update/command.rs | 31 +++- src/entities/dim_override.rs | 14 ++ src/entities/dimension.rs | 272 +++++++++++++++++++++++++++++------ 3 files changed, 269 insertions(+), 48 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index ab25667d..76a545e7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1144,6 +1144,31 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { | GripMenuAction::MoveWithLeader | GripMenuAction::MoveIndependent ) { + let is_dimension = matches!( + self.tabs[i].scene.document.get_entity(popup.handle), + Some(acadrust::EntityType::Dimension(_)) + ); + if is_dimension { + let movement = match item.action { + GripMenuAction::MoveWithDimLine => Some("Keep dim line with text"), + GripMenuAction::MoveWithLeader => Some("Move text, add leader"), + GripMenuAction::MoveIndependent => Some("Move text, no leader"), + _ => None, + }; + if let Some(movement) = movement { + crate::entities::dim_override::set_property( + &mut self.tabs[i].scene.document, + popup.handle, + "dim_text_movement", + movement, + ); + self.tabs[i].dirty = true; + self.tabs[i].scene.bump_entities(&[( + popup.handle, + crate::scene::ChangeKind::Modified, + )]); + } + } // Stretch / Move = grab this grip. Engage it so the next // click places it (click-move-click) — same as picking the // grip directly in the viewport. Without this the menu just @@ -1157,10 +1182,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // "Move with Leader" drags the whole multileader; the // others move just the picked grip. let (grip_id, is_translate) = - if matches!(item.action, GripMenuAction::MoveWithLeader) { + if matches!(item.action, GripMenuAction::MoveWithLeader) + && !is_dimension + { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { - (popup.grip_id, g.is_midpoint) + (popup.grip_id, if is_dimension { false } else { g.is_midpoint }) }; self.tabs[i].active_grip = Some(GripEdit::single( popup.handle, diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 13ce51b8..aa568dcb 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -274,6 +274,8 @@ pub fn property_int_code(field: &str) -> Option { "dim_line_inside" => DIMSOXD, "dim_units" => DIMLUNIT, "dim_precision" => DIMDEC, + "dim_angle_units" => DIMAUNIT, + "dim_angle_precision" => DIMADEC, "dim_decimal_separator" => DIMDSEP, "dim_fractional_type" => DIMFRAC, "dim_text_view_direction" => DIMTXTDIRECTION, @@ -330,6 +332,9 @@ fn inherited_int(doc: &CadDocument, handle: Handle, code: i16) -> i16 { DIMTOL => style.dimtol as i16, DIMLIM => style.dimlim as i16, DIMALT => style.dimalt as i16, + DIMAZIN => style.dimazin, + DIMAUNIT => style.dimaunit, + DIMADEC => style.dimadec, DIMTFILL => style.dimtfill, DIMTALN => 0, _ => 0, @@ -451,6 +456,8 @@ pub fn set_property( "dim_tolerance_suppress_trailing_zeros" => Some((DIMTZIN, 8)), "dim_alt_tolerance_suppress_leading_zeros" => Some((DIMALTTZ, 4)), "dim_alt_tolerance_suppress_trailing_zeros" => Some((DIMALTTZ, 8)), + "dim_angle_suppress_leading_zeros" => Some((DIMAZIN, 1)), + "dim_angle_suppress_trailing_zeros" => Some((DIMAZIN, 2)), _ => None, }; if let Some((code, bit)) = bit_field { @@ -624,6 +631,13 @@ pub fn set_property( "desktop" => Some(6), _ => trimmed.parse().ok(), }, + "dim_angle_units" => match trimmed.to_ascii_lowercase().as_str() { + "decimal degrees" => Some(0), + "degrees/minutes/seconds" => Some(1), + "gradians" => Some(2), + "radians" => Some(3), + _ => trimmed.parse().ok(), + }, "dim_fractional_type" => match trimmed.to_ascii_lowercase().as_str() { "horizontal" => Some(0), "diagonal" => Some(1), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index ad6cd235..0e0d8123 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -32,7 +32,7 @@ fn dimension_definition_point(dim: &Dimension) -> acadrust::types::Vector3 { Dimension::Linear(d) => d.definition_point, Dimension::Radius(d) => d.definition_point, Dimension::Diameter(d) => d.definition_point, - Dimension::Angular2Ln(d) => d.definition_point, + Dimension::Angular2Ln(d) => d.dimension_arc, Dimension::Angular3Pt(d) => d.definition_point, Dimension::Ordinate(d) => d.definition_point, Dimension::Arc(d) => d.definition_point, @@ -107,6 +107,18 @@ fn properties(dim: &Dimension) -> Vec { ], }]; } + if matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![crate::scene::model::object::Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: crate::scene::model::object::PropValue::PlainText( + dim.base().style_name.clone(), + ), + }], + }]; + } let mut props = base_props(dim.base()); match dim { Dimension::Aligned(d) => { @@ -1024,6 +1036,12 @@ fn restore_dim_text_relative_position( } fn dimension_line_grip_position(dim: &Dimension) -> Option { + match dim { + Dimension::Angular2Ln(d) => return Some(dv3(&d.dimension_arc)), + Dimension::Angular3Pt(d) => return Some(dv3(&d.definition_point)), + Dimension::Arc(d) => return Some(dv3(&d.definition_point)), + _ => {} + } let (first, second, defpt, ax, ay) = match dim { Dimension::Linear(d) => ( d.first_point, @@ -1139,11 +1157,12 @@ impl Grippable for Dimension { center_grip(2, text), ], Dimension::Angular2Ln(d) => vec![ - square_grip(0, dv3(&d.angle_vertex)), - center_grip(1, dv3(&d.first_point)), - center_grip(2, dv3(&d.second_point)), + square_grip(0, dv3(&d.first_point)), + center_grip(1, dv3(&d.second_point)), + center_grip(2, dv3(&d.angle_vertex)), center_grip(3, dv3(&d.definition_point)), - center_grip(4, text), + center_grip(4, dv3(&d.dimension_arc)), + center_grip(5, text), ], Dimension::Angular3Pt(d) => vec![ square_grip(0, dv3(&d.angle_vertex)), @@ -1191,7 +1210,8 @@ impl Grippable for Dimension { let text_grip = match self { Dimension::Linear(_) | Dimension::Aligned(_) => 3, Dimension::Radius(_) | Dimension::Diameter(_) => 2, - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => 4, + Dimension::Angular2Ln(_) => 5, + Dimension::Angular3Pt(_) => 4, Dimension::Ordinate(_) => 3, Dimension::Arc(d) => if d.has_leader { 6 } else { 4 }, Dimension::LargeRadial(_) => 4, @@ -1234,10 +1254,11 @@ impl Grippable for Dimension { _ => {} }, Dimension::Angular2Ln(d) => match grip_id { - 0 => apply_to_v3(&mut d.angle_vertex, &apply), - 1 => apply_to_v3(&mut d.first_point, &apply), - 2 => apply_to_v3(&mut d.second_point, &apply), + 0 => apply_to_v3(&mut d.first_point, &apply), + 1 => apply_to_v3(&mut d.second_point, &apply), + 2 => apply_to_v3(&mut d.angle_vertex, &apply), 3 => apply_to_v3(&mut d.definition_point, &apply), + 4 => apply_to_v3(&mut d.dimension_arc, &apply), _ => {} }, Dimension::Angular3Pt(d) => match grip_id { @@ -1285,7 +1306,8 @@ impl Grippable for Dimension { let (dim_line_grip, text_grip) = match self { Dimension::Linear(_) | Dimension::Aligned(_) => (2, 3), Dimension::Radius(_) | Dimension::Diameter(_) => (1, 2), - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => (3, 4), + Dimension::Angular2Ln(_) => (4, 5), + Dimension::Angular3Pt(_) => (3, 4), Dimension::Ordinate(_) => (0, 3), Dimension::Arc(d) => (3, if d.has_leader { 6 } else { 4 }), Dimension::LargeRadial(_) => (3, 4), @@ -1353,7 +1375,8 @@ impl Grippable for Dimension { let (_dim_line_grip, text_grip) = match self { Dimension::Linear(_) | Dimension::Aligned(_) => (2, 3), Dimension::Radius(_) | Dimension::Diameter(_) => (1, 2), - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => (3, 4), + Dimension::Angular2Ln(_) => (4, 5), + Dimension::Angular3Pt(_) => (3, 4), Dimension::Ordinate(_) => (0, 3), Dimension::Arc(d) => (3, if d.has_leader { 6 } else { 4 }), Dimension::LargeRadial(_) => (3, 4), @@ -1400,7 +1423,8 @@ impl Grippable for Dimension { let text_grip = match self { Dimension::Linear(_) | Dimension::Aligned(_) => 3, Dimension::Radius(_) | Dimension::Diameter(_) => 2, - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => 4, + Dimension::Angular2Ln(_) => 5, + Dimension::Angular3Pt(_) => 4, Dimension::Ordinate(_) => 3, Dimension::Arc(d) => if d.has_leader { 6 } else { 4 }, Dimension::LargeRadial(_) => 4, @@ -1641,7 +1665,7 @@ pub fn style_sections( _ => choice_value("None", &["None", "Background", "Color"]), }; - vec![ + let mut sections = vec![ PropSection { title: t!("Lines & Arrows").into_owned(), props: vec![ @@ -1968,19 +1992,19 @@ pub fn style_sections( text( t!("Decimal separator").as_ref(), "dim_decimal_separator", - decimal_separator, + decimal_separator.clone(), true, ), text( t!("Dim prefix").as_ref(), "dim_prefix", - dim_prefix, + dim_prefix.clone(), true, ), text( t!("Dim suffix").as_ref(), "dim_suffix", - dim_suffix, + dim_suffix.clone(), true, ), text( @@ -2271,7 +2295,81 @@ pub fn style_sections( ), ], }, - ] + ]; + + if matches!(dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { + if let Some(text_section) = sections + .iter_mut() + .find(|section| section.title == t!("Text").as_ref()) + { + text_section + .props + .retain(|property| property.field != "dim_fractional_type"); + } + + let angle_zero_suppression = int(ov::DIMAZIN, s.dimazin); + let angle_unit = int(ov::DIMAUNIT, s.dimaunit); + if let Some(primary_units) = sections + .iter_mut() + .find(|section| section.title == t!("Primary Units").as_ref()) + { + primary_units.props = vec![ + text( + t!("Decimal separator").as_ref(), + "dim_decimal_separator", + decimal_separator, + true, + ), + text(t!("Dim prefix").as_ref(), "dim_prefix", dim_prefix, true), + text(t!("Dim suffix").as_ref(), "dim_suffix", dim_suffix, true), + choice( + t!("Angle format").as_ref(), + "dim_angle_units", + angular_unit_label(angle_unit), + &[ + "Decimal degrees", + "Degrees/minutes/seconds", + "Gradians", + "Radians", + ], + true, + ), + choice( + t!("Suppress leading zeros").as_ref(), + "dim_angle_suppress_leading_zeros", + yes(angle_zero_suppression & 1 != 0), + &["Yes", "No"], + true, + ), + choice( + t!("Suppress trailing zeros").as_ref(), + "dim_angle_suppress_trailing_zeros", + yes(angle_zero_suppression & 2 != 0), + &["Yes", "No"], + true, + ), + property( + t!("Precision").as_ref(), + "dim_angle_precision", + PropValue::Choice { + selected: int(ov::DIMADEC, s.dimadec).to_string(), + options: (0..=8).map(|value| value.to_string()).collect(), + }, + ), + ]; + } + sections.retain(|section| section.title != t!("Alternate Units").as_ref()); + if let Some(tolerances) = sections + .iter_mut() + .find(|section| section.title == t!("Tolerances").as_ref()) + { + tolerances + .props + .retain(|property| !property.field.starts_with("dim_alt_tolerance_")); + } + } + + sections } fn property(label: &str, field: &'static str, value: PropValue) -> Property { @@ -2355,6 +2453,15 @@ fn alternate_unit_label(value: i16) -> &'static str { } } +fn angular_unit_label(value: i16) -> &'static str { + match value { + 1 => "Degrees/minutes/seconds", + 2 => "Gradians", + 3 => "Radians", + _ => "Decimal degrees", + } +} + fn tolerance_vertical_label(value: i16) -> &'static str { match value { 0 => "Bottom", @@ -3369,6 +3476,8 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { } Dimension::Radius(d) => lv(d.definition_point), Dimension::Diameter(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5, + Dimension::Angular2Ln(d) => lv(d.dimension_arc), + Dimension::Angular3Pt(d) => lv(d.definition_point), _ => return None, }; Some((anchor, lv(txt))) @@ -3566,7 +3675,12 @@ fn dimension_geometry( // ten degrees came out as two hundred and seventy. let (p1, p2) = (lv(d.first_point), lv(d.second_point)); let (p3, p4) = (lv(d.angle_vertex), lv(d.definition_point)); - let arc_point = lv(d.dimension_arc); + let stored_arc = if d.dimension_arc.length_squared() > 1.0e-18 { + d.dimension_arc + } else { + d.base.definition_point + }; + let arc_point = lv(stored_arc); match two_line_angle_frame(p1, p2, p3, p4, arc_point) { Some((vertex, start, end)) => append_angular_dimension( &mut g, @@ -3577,6 +3691,8 @@ fn dimension_geometry( arrow1, arrow2, Some((start, end)), + params, + suppress, ), // Parallel lines have no vertex and so no angle to draw; the // extension lines alone say where the dimension was. @@ -3587,15 +3703,29 @@ fn dimension_geometry( } } Dimension::Angular3Pt(d) => { + let vertex = lv(d.angle_vertex); + let first = lv(d.first_point); + let second = lv(d.second_point); + let arc_point = lv(d.definition_point); + let explicit_sweep = two_line_angle_frame( + vertex, + first, + vertex, + second, + arc_point, + ) + .map(|(_, start, end)| (start, end)); append_angular_dimension( &mut g, - lv(d.angle_vertex), - lv(d.first_point), - lv(d.second_point), - lv(d.definition_point), + vertex, + first, + second, + arc_point, arrow1, arrow2, - None, + explicit_sweep, + params, + suppress, ); } Dimension::Ordinate(d) => { @@ -3623,6 +3753,8 @@ fn dimension_geometry( d.arc_start_parameter as f32, d.arc_end_parameter as f32, )), + params, + suppress, ); if d.has_leader { add_segment( @@ -3933,6 +4065,8 @@ fn append_angular_dimension( arrow1: &ArrowKind, arrow2: &ArrowKind, explicit_sweep: Option<(f32, f32)>, + params: DimLineParams, + suppress: SuppressFlags, ) { let radius = vertex.distance(arc_point); if radius <= 1e-6 { @@ -3947,8 +4081,30 @@ fn append_angular_dimension( let (start, mut end) = explicit_sweep.unwrap_or((measured_start, measured_end)); let dir1 = Vec3::new(start.cos(), start.sin(), 0.0); let dir2 = Vec3::new(end.cos(), end.sin(), 0.0); - add_segment(&mut g.ext_lines, first, vertex + dir1 * radius); - add_segment(&mut g.ext_lines, second, vertex + dir2 * radius); + let arc_start = vertex + dir1 * radius; + let arc_end = vertex + dir2 * radius; + let extension = |origin: Vec3, endpoint: Vec3, direction: Vec3| { + if params.dimfxlon { + ( + endpoint - direction * params.dimfxl.max(0.0), + endpoint + direction * params.dimexe, + ) + } else { + let toward_arc = normalized_or(endpoint - origin, direction); + ( + origin + toward_arc * params.dimexo, + endpoint + toward_arc * params.dimexe, + ) + } + }; + let (ext1_start, ext1_end) = extension(first, arc_start, dir1); + let (ext2_start, ext2_end) = extension(second, arc_end, dir2); + if !suppress.ext1 { + add_segment(&mut g.ext_lines, ext1_start, ext1_end); + } + if !suppress.ext2 { + add_segment(&mut g.ext_lines, ext2_start, ext2_end); + } let mut delta = end - start; // Wrap a negative sweep forwards, but leave a zero one alone: two rays that @@ -3965,14 +4121,28 @@ fn append_angular_dimension( delta = end - start; } - let steps = 32; + let arc_extension = if params.ticks && radius > 1.0e-6 { + params.dimdle / radius + } else { + 0.0 + }; + let direction = delta.signum(); + let draw_start = start - direction * arc_extension; + let draw_delta = delta + direction * arc_extension * 2.0; + let steps = angular_arc_steps(draw_delta); let mut arc_pts = Vec::with_capacity((steps + 1) as usize); for i in 0..=steps { let t = i as f32 / steps as f32; - let a = start + delta * t; + let a = draw_start + draw_delta * t; arc_pts.push(vertex + Vec3::new(a.cos() * radius, a.sin() * radius, 0.0)); } - add_polyline(&mut g.dim_lines, &arc_pts); + let midpoint = arc_pts.len() / 2; + if !suppress.dim1 && midpoint > 0 { + add_polyline(&mut g.dim_lines, &arc_pts[..=midpoint]); + } + if !suppress.dim2 && midpoint + 1 < arc_pts.len() { + add_polyline(&mut g.dim_lines, &arc_pts[midpoint..]); + } if arc_pts.len() >= 2 { append_arrow( @@ -3991,6 +4161,10 @@ fn append_angular_dimension( } } +fn angular_arc_steps(sweep: f32) -> u32 { + ((sweep.abs() / std::f32::consts::PI * 90.0).ceil() as u32).clamp(8, 720) +} + fn dimension_snap_pts(dim: &Dimension) -> Vec<(glam::DVec3, SnapHint)> { let lv = |v: acadrust::types::Vector3| glam::DVec3::new(v.x, v.y, v.z); let node = |v: acadrust::types::Vector3| (lv(v), SnapHint::Node); @@ -4008,10 +4182,11 @@ fn dimension_snap_pts(dim: &Dimension) -> Vec<(glam::DVec3, SnapHint)> { Dimension::Radius(d) => vec![node(d.angle_vertex), node(d.definition_point)], Dimension::Diameter(d) => vec![node(d.angle_vertex), node(d.definition_point)], Dimension::Angular2Ln(d) => vec![ - node(d.angle_vertex), node(d.first_point), node(d.second_point), + node(d.angle_vertex), node(d.definition_point), + node(d.dimension_arc), ], Dimension::Angular3Pt(d) => vec![ node(d.angle_vertex), @@ -4771,35 +4946,44 @@ fn reduce_fraction(mut n: u64, mut d: u64) -> String { /// Format an angular measurement (input in degrees as Dimension::measurement /// returns for angular variants) honouring DIMAUNIT, DIMADEC, DIMAZIN. fn format_angular_value(measurement_deg: f64, style: Option<&DimStyle>) -> String { - let (aunit, adec, azin) = style - .map(|s| (s.dimaunit, s.dimadec, s.dimazin)) - .unwrap_or((0, 2, 0)); + let (aunit, adec, azin, decimal_separator) = style + .map(|s| (s.dimaunit, s.dimadec, s.dimazin, s.dimdsep)) + .unwrap_or((0, 2, 0, b'.' as i16)); let adec = adec.max(0) as usize; match aunit { // 1 = Degrees / Minutes / Seconds - 1 => format_dms(measurement_deg, adec, azin), + 1 => format_dms(measurement_deg, adec, azin, decimal_separator), // 2 = Gradians 2 => { let g = measurement_deg / 0.9; let raw = format!("{:.*}", adec, g); - format!("{}g", apply_angular_zero_suppression(&raw, azin)) + format!( + "{}g", + swap_decimal_sep(&apply_angular_zero_suppression(&raw, azin), decimal_separator) + ) } // 3 = Radians 3 => { let r = measurement_deg.to_radians(); let raw = format!("{:.*}", adec, r); - format!("{}r", apply_angular_zero_suppression(&raw, azin)) + format!( + "{}r", + swap_decimal_sep(&apply_angular_zero_suppression(&raw, azin), decimal_separator) + ) } // 0 or unknown = Decimal Degrees _ => { let raw = format!("{:.*}", adec, measurement_deg); - format!("{}°", apply_angular_zero_suppression(&raw, azin)) + format!( + "{}°", + swap_decimal_sep(&apply_angular_zero_suppression(&raw, azin), decimal_separator) + ) } } } -fn format_dms(deg: f64, sec_dec: usize, azin: i16) -> String { +fn format_dms(deg: f64, sec_dec: usize, azin: i16, decimal_separator: i16) -> String { let sign = if deg < 0.0 { "-" } else { "" }; let abs = deg.abs(); let d = abs.floor(); @@ -4807,15 +4991,11 @@ fn format_dms(deg: f64, sec_dec: usize, azin: i16) -> String { let m = m_full.floor(); let s = (m_full - m) * 60.0; let s_str = format!("{:.*}", sec_dec, s); - let mut out = format!("{}{:.0}°{:.0}'{}\"", sign, d, m, s_str); - if azin & 4 != 0 { - // suppress 0° / 0' parts - if d == 0.0 { - out = out.trim_start_matches('0').to_string(); - out = out.replacen("°", "", 1); - } - } - out + let s_str = swap_decimal_sep( + &apply_angular_zero_suppression(&s_str, azin), + decimal_separator, + ); + format!("{}{:.0}°{:.0}'{}\"", sign, d, m, s_str) } /// Apply DIMZIN bit flags to a formatted linear value. From a8f05716cff05b63bcc77ee7b06f0013a5a70bd6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 11:29:39 +0300 Subject: [PATCH 48/98] Use corrected angular dimension data model --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 808da012..1e679889 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b2ae66#5b2ae66d0bd7b0da2d13392d3c3332f8a39caa1f" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=aa527f3#aa527f35399cba500b0c923d1e15f52286820565" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index dc49bfb1..4768d315 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "aa527f3", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "aa527f3" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } From 24b344b2b2f40148b8f68c9111b5cdea126f6212 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:23:21 +0300 Subject: [PATCH 49/98] Complete angular dimension interaction and associations --- src/app/command_driver.rs | 67 +++ src/app/mod.rs | 6 + src/app/mtext_editor.rs | 12 + src/app/update/file.rs | 7 + src/command.rs | 51 +++ src/modules/annotate/angular_dim.rs | 620 ++++++++++++++++++++++++---- src/scene/dimension_assoc.rs | 89 +++- 7 files changed, 777 insertions(+), 75 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1bff0d72..6845b777 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -581,6 +581,36 @@ impl OpenCADStudio { let _ = self.apply_cmd_result(r); return; } + let accepts_points = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| command.entity_pick_accepts_points()); + if accepts_points { + if let Some((coord, kind)) = super::helpers::parse_coord(token) { + let ucs = self.tabs[i].active_ucs.clone(); + let wcs = match ( + matches!(kind, super::helpers::CoordKind::Relative), + self.last_point, + ) { + (true, Some(base)) => { + base + match &ucs { + Some(value) => super::helpers::ucs_rotate_vec(coord, value), + None => coord, + } + } + _ => match &ucs { + Some(value) => super::helpers::ucs_to_wcs(coord, value), + None => coord, + }, + }; + if self.command_point_allowed(i, wcs) { + self.last_point = Some(wcs); + self.push_ucs_to_cmd(i); + let _ = self.feed_command(StepInput::Point(wcs)); + } + return; + } + } if let Ok(v) = u64::from_str_radix(token.trim_start_matches("0x"), 16) { let handle = Handle::new(v); let pt = self.tabs[i] @@ -1257,6 +1287,30 @@ impl OpenCADStudio { self.commit_undo_delta(i, pending); } } + CmdResult::CommitAssociativeDimensionDetailed { entity, sources } => { + let label = self.history_label_from_active_cmd(i, "DIMENSION"); + let pending = self.begin_undo(i, label, 1, false); + if let Some(handle) = self.commit_entity_handle(entity) { + self.tabs[i] + .scene + .attach_dimension_association_sources(handle, sources.clone()); + let mut changed = vec![(handle, crate::scene::ChangeKind::Modified)]; + changed.extend(sources.iter().flatten().map(|source| { + (source.handle, crate::scene::ChangeKind::Modified) + })); + changed.sort_by_key(|(handle, _)| handle.value()); + changed.dedup_by_key(|(handle, _)| handle.value()); + self.tabs[i].scene.bump_entities(&changed); + } + self.tabs[i].dirty = true; + self.tabs[i].scene.clear_preview_wire(); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + if let Some(pending) = pending { + self.commit_undo_delta(i, pending); + } + } CmdResult::CommitSolid { entity, solid, @@ -3572,6 +3626,19 @@ impl OpenCADStudio { self.tabs[i].snap_result = None; self.open_mtext_editor(pos, handle, &initial, height); } + CmdResult::SuspendForMTextInput { + pos, + initial, + height, + } => { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + self.command_mtext_input = true; + self.pending_command_editor_text = None; + self.open_mtext_editor(pos, None, &initial, height); + } CmdResult::OpenTextEditor { pos, handle, diff --git a/src/app/mod.rs b/src/app/mod.rs index 3830c21d..927a40bf 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -764,6 +764,10 @@ pub(super) struct OpenCADStudio { ctrl_down: bool, /// Open in-place MText editor (toolbar + text area + live preview), if any. mtext_editor: Option, + /// The current rich-text editor belongs to a suspended drawing command and + /// must return text instead of creating a standalone MText entity. + command_mtext_input: bool, + pending_command_editor_text: Option, /// Open in-place single-line TEXT editor (plain text-entry box), if any. text_inline: Option, /// Cursor-anchored one-shot snap override menu (Shift+RMB): the canvas @@ -3252,6 +3256,8 @@ impl OpenCADStudio { ctrl_down: false, cont_anchor: None, mtext_editor: None, + command_mtext_input: false, + pending_command_editor_text: None, text_inline: None, snap_override_popup: None, axis_lock_dir: None, diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index f4566d81..f5dae78e 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -1209,6 +1209,12 @@ impl super::OpenCADStudio { pub(super) fn mtext_commit(&mut self) -> bool { let i = self.active_tab; let Some(ed) = self.mtext_editor.take() else { return false }; + if self.command_mtext_input { + self.pending_command_editor_text = Some(ed.build_mtext().value); + self.command_mtext_input = false; + self.refresh_properties(); + return true; + } let body_empty = ed.content.text().trim().is_empty(); let mut mt = ed.build_mtext(); let annotative = ed.editing.is_none() @@ -1302,6 +1308,10 @@ impl super::OpenCADStudio { /// then updated in place on later applies, so repeated Apply never leaves /// duplicate entities behind. pub(super) fn mtext_apply(&mut self) { + if self.command_mtext_input { + self.rebuild_mtext_preview(); + return; + } let i = self.active_tab; let (mut mt, editing, body_empty) = match self.mtext_editor.as_ref() { Some(ed) => ( @@ -1398,6 +1408,8 @@ impl super::OpenCADStudio { /// Discard the editor without changing the drawing. pub(super) fn mtext_cancel(&mut self) { self.mtext_editor = None; + self.command_mtext_input = false; + self.pending_command_editor_text = None; self.reset_modal_geometry(); } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 8615be0e..10a95940 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -492,6 +492,13 @@ impl OpenCADStudio { self.reset_modal_geometry(); let i = self.active_tab; if let Some(mut cmd) = self.tabs[i].suspended_cmd.take() { + if committed { + if let Some(value) = self.pending_command_editor_text.take() { + cmd.on_editor_text(value); + } + } else { + self.pending_command_editor_text = None; + } let res = cmd.on_editor_closed(committed); self.tabs[i].active_cmd = Some(cmd); self.apply_cmd_result(res) diff --git a/src/command.rs b/src/command.rs index b8dbdb6f..22440bca 100644 --- a/src/command.rs +++ b/src/command.rs @@ -169,6 +169,34 @@ pub struct SelectionEntity { pub surface_area: Option, } +/// One source point used by an associative dimension. A missing marker asks +/// the scene to infer the closest stable sub-entity; an explicit marker keeps +/// segment-level references intact. +#[derive(Clone, Copy, Debug)] +pub struct DimensionAssociationSource { + pub handle: Handle, + pub marker: Option, + pub parameter: f64, +} + +impl DimensionAssociationSource { + pub const fn inferred(handle: Handle) -> Self { + Self { + handle, + marker: None, + parameter: 0.0, + } + } + + pub const fn explicit(handle: Handle, marker: i32, parameter: f64) -> Self { + Self { + handle, + marker: Some(marker), + parameter, + } + } +} + #[derive(Clone)] pub enum AreaPreviewSource { Handles(Vec), @@ -1217,6 +1245,12 @@ pub enum CmdResult { entity: EntityType, sources: Vec, }, + /// Commit a dimension whose definition points carry explicit sub-entity + /// references. `None` leaves that definition point independent. + CommitAssociativeDimensionDetailed { + entity: EntityType, + sources: Vec>, + }, /// Commit a Model-tab 3D solid: the acadrust entity (for selection / /// persistence) plus its B-rep (cached for boolean ops + shaded /// rendering). Ends the command. @@ -1396,6 +1430,13 @@ pub enum CmdResult { initial: String, height: f64, }, + /// Temporarily suspend the active command and collect formatted text in the + /// rich editor without creating a standalone MText entity. + SuspendForMTextInput { + pos: DVec3, + initial: String, + height: f64, + }, /// Open the in-place single-line TEXT editor (a plain text-entry box, no /// formatting toolbar). `handle` is `Some` when editing an existing Text, /// `None` when creating a new one at `pos`. @@ -1777,6 +1818,12 @@ pub trait CadCommand: Send { false } + /// Allow typed coordinates while object picking is active. Commands whose + /// first prompt accepts either an object or a point opt in explicitly. + fn entity_pick_accepts_points(&self) -> bool { + false + } + /// Include filled hatch / DXF SOLID regions in the entity hit-test. /// /// Most entity-pick commands operate on curve geometry and intentionally @@ -1805,6 +1852,10 @@ pub trait CadCommand: Send { CmdResult::Cancel } + /// Supplies formatted text collected by `SuspendForMTextInput` before the + /// command is resumed through `on_editor_closed`. + fn on_editor_text(&mut self, _value: String) {} + /// Called when the user clicks and `needs_entity_pick()` is true. /// `handle` is the nearest wire's entity handle (Handle::NULL if nothing found). fn on_entity_pick(&mut self, _handle: Handle, _pt: DVec3) -> CmdResult { diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index d6e9edf9..4b7dc3fb 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -2,7 +2,9 @@ use acadrust::entities::{Dimension, DimensionAngular2Ln, DimensionAngular3Pt}; use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; -use crate::command::{CadCommand, CmdResult, WorkingPlane}; +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationSource, WorkingPlane, +}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; use crate::t; @@ -26,13 +28,13 @@ enum Step { CircleSecondRay { vertex: DVec3, first: DVec3, - radius: f64, source: Handle, }, SecondLine { first_start: DVec3, first_end: DVec3, first_source: Handle, + first_refs: [SourceLocator; 2], }, ArcPoint3 { vertex: DVec3, @@ -47,16 +49,44 @@ enum Step { }, } +#[derive(Clone, Copy, Debug, PartialEq)] +struct SourceLocator { + marker: Option, + parameter: f64, +} + +impl SourceLocator { + const INFERRED: Self = Self { marker: None, parameter: 0.0 }; + + const fn explicit(marker: i32, parameter: f64) -> Self { + Self { marker: Some(marker), parameter } + } + + fn bind(self, handle: Handle) -> DimensionAssociationSource { + match self.marker { + Some(marker) => DimensionAssociationSource::explicit(handle, marker, self.parameter), + None => DimensionAssociationSource::inferred(handle), + } + } +} + enum PickedCurve { - Line(DVec3, DVec3), + Line { + start: DVec3, + end: DVec3, + refs: [SourceLocator; 2], + normal: Option, + }, Arc { center: DVec3, first: DVec3, second: DVec3, + refs: [SourceLocator; 3], + normal: DVec3, }, Circle { center: DVec3, - radius: f64, + normal: DVec3, }, } @@ -65,12 +95,15 @@ pub struct AngularDimensionCommand { plane: WorkingPlane, selecting_object: bool, picked_entity: Option, - source_handles: Vec, + source_refs: Vec>, text_override: Option, awaiting_text: bool, mtext_override: bool, text_angle: Option, awaiting_angle: bool, + angle_origin: Option, + awaiting_quadrant: bool, + quadrant_lock: Option<(f64, f64)>, } impl AngularDimensionCommand { @@ -78,14 +111,17 @@ impl AngularDimensionCommand { Self { step: Step::Vertex, plane: WorkingPlane::default(), - selecting_object: false, + selecting_object: true, picked_entity: None, - source_handles: Vec::new(), + source_refs: Vec::new(), text_override: None, awaiting_text: false, mtext_override: false, text_angle: None, awaiting_angle: false, + angle_origin: None, + awaiting_quadrant: false, + quadrant_lock: None, } } @@ -99,12 +135,23 @@ impl AngularDimensionCommand { let vertex = self.plane.to_local(vertex); let first = self.plane.to_local(first); let second = self.plane.to_local(second); - let arc_point = self.plane.to_local(arc_point); + let picked_text = self.plane.to_local(arc_point); + let arc_point = locked_arc_point(vertex, picked_text, self.quadrant_lock); + if two_line_frame(vertex, first, vertex, second, arc_point).is_none() { + return CmdResult::NeedPoint; + } let mut dim = DimensionAngular3Pt::new(v3(vertex), v3(first), v3(second)); dim.definition_point = v3(arc_point); dim.base.definition_point = dim.definition_point; dim.base.text_middle_point = dim.definition_point; dim.base.insertion_point = dim.definition_point; + if self.quadrant_lock.is_some_and(|frame| { + !point_angle_in_frame(vertex, picked_text, frame) + }) { + dim.base.text_middle_point = v3(picked_text); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.text_user_positioned = true; + } dim.base.actual_measurement = dim.measurement_degrees(); crate::entities::dimension::set_dimension_text_override( &mut dim.base, @@ -128,7 +175,28 @@ impl AngularDimensionCommand { let first_end = self.plane.to_local(first_end); let second_start = self.plane.to_local(second_start); let second_end = self.plane.to_local(second_end); - let arc_point = self.plane.to_local(arc_point); + let picked_text = self.plane.to_local(arc_point); + let Some((vertex, _, _)) = two_line_frame( + first_start, + first_end, + second_start, + second_end, + picked_text, + ) else { + return CmdResult::NeedPoint; + }; + let arc_point = locked_arc_point(vertex, picked_text, self.quadrant_lock); + if two_line_frame( + first_start, + first_end, + second_start, + second_end, + arc_point, + ) + .is_none() + { + return CmdResult::NeedPoint; + } let mut dim = DimensionAngular2Ln::default(); dim.first_point = v3(first_start); dim.second_point = v3(first_end); @@ -138,6 +206,13 @@ impl AngularDimensionCommand { dim.base.definition_point = dim.dimension_arc; dim.base.text_middle_point = dim.dimension_arc; dim.base.insertion_point = dim.dimension_arc; + if self.quadrant_lock.is_some_and(|frame| { + !point_angle_in_frame(vertex, picked_text, frame) + }) { + dim.base.text_middle_point = v3(picked_text); + dim.base.insertion_point = dim.base.text_middle_point; + dim.base.text_user_positioned = true; + } dim.base.actual_measurement = dim.measurement_degrees(); crate::entities::dimension::set_dimension_text_override( &mut dim.base, @@ -151,12 +226,12 @@ impl AngularDimensionCommand { fn commit(&self, dimension: Dimension) -> CmdResult { let entity = self.plane.place_entity(EntityType::Dimension(dimension)); - if self.source_handles.is_empty() { + if self.source_refs.iter().all(Option::is_none) { CmdResult::CommitAndExit(entity) } else { - CmdResult::CommitAssociativeDimension { + CmdResult::CommitAssociativeDimensionDetailed { entity, - sources: self.source_handles.clone(), + sources: self.source_refs.clone(), } } } @@ -164,6 +239,81 @@ impl AngularDimensionCommand { fn placement_step(&self) -> bool { matches!(self.step, Step::ArcPoint3 { .. } | Step::ArcPoint2 { .. }) } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::ArcPoint3 { vertex, first, second } => { + let radius = vertex.distance(first).max(vertex.distance(second)).max(1.0); + let first_angle = self.plane.angle(vertex, first).unwrap_or(0.0); + let second_angle = self.plane.angle(vertex, second).unwrap_or(first_angle); + let sweep = (second_angle - first_angle).rem_euclid(std::f64::consts::TAU); + let angle = first_angle + sweep * 0.5; + self.plane.to_world( + self.plane.to_local(vertex) + + DVec3::new(angle.cos() * radius, angle.sin() * radius, 0.0), + ) + } + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => { + let first_start = self.plane.to_local(first_start); + let first_end = self.plane.to_local(first_end); + let second_start = self.plane.to_local(second_start); + let second_end = self.plane.to_local(second_end); + two_line_frame( + first_start, + first_end, + second_start, + second_end, + (first_start + second_start) * 0.5, + ) + .map(|(vertex, start, end)| { + self.plane.to_world(vertex + DVec3::new( + ((start + end) * 0.5).cos(), + ((start + end) * 0.5).sin(), + 0.0, + )) + }) + .unwrap_or_else(|| self.plane.to_world(first_start)) + } + _ => self.plane.origin, + } + } + + fn set_quadrant_from_point(&mut self, point: DVec3) -> bool { + let point = self.plane.to_local(point); + let frame = match self.step { + Step::ArcPoint3 { vertex, first, second } => two_line_frame( + self.plane.to_local(vertex), + self.plane.to_local(first), + self.plane.to_local(vertex), + self.plane.to_local(second), + point, + ), + Step::ArcPoint2 { + first_start, + first_end, + second_start, + second_end, + } => two_line_frame( + self.plane.to_local(first_start), + self.plane.to_local(first_end), + self.plane.to_local(second_start), + self.plane.to_local(second_end), + point, + ), + _ => None, + }; + if let Some((_, start, end)) = frame { + self.quadrant_lock = Some((start, end)); + true + } else { + false + } + } } impl CadCommand for AngularDimensionCommand { @@ -176,6 +326,9 @@ impl CadCommand for AngularDimensionCommand { } fn prompt(&self) -> String { + if self.awaiting_quadrant { + return t!("DIMANGULAR Specify quadrant:").into_owned(); + } if self.awaiting_text { return if self.mtext_override { t!("DIMANGULAR Enter formatted dimension text (blank = measured value):") @@ -185,17 +338,24 @@ impl CadCommand for AngularDimensionCommand { }; } if self.awaiting_angle { - return t!("DIMANGULAR Specify text angle (degrees):").into_owned(); + return if self.angle_origin.is_some() { + t!("DIMANGULAR Specify second point for text angle:").into_owned() + } else { + t!("DIMANGULAR Specify text angle or first point:").into_owned() + }; } if self.selecting_object { return match self.step { Step::SecondLine { .. } => t!("DIMANGULAR Select second line:").into_owned(), - _ => t!("DIMANGULAR Select arc, circle, line, or polyline arc:").into_owned(), + _ => t!( + "DIMANGULAR Select arc, circle, line, or specify an angle vertex:" + ) + .into_owned(), }; } match self.step { Step::Vertex => t!( - "DIMANGULAR Specify angle vertex or press Enter to select an object:" + "DIMANGULAR Specify angle vertex:" ) .into_owned(), Step::FirstRay(_) => { @@ -213,6 +373,29 @@ impl CadCommand for AngularDimensionCommand { } fn on_point(&mut self, point: DVec3) -> CmdResult { + if self.awaiting_quadrant { + if self.set_quadrant_from_point(point) { + self.awaiting_quadrant = false; + } + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + if let Some(origin) = self.angle_origin { + if let Some(angle) = self.plane.angle(origin, point) { + self.text_angle = Some(angle); + self.awaiting_angle = false; + self.angle_origin = None; + } + } else { + self.angle_origin = Some(point); + } + return CmdResult::NeedPoint; + } + if self.selecting_object && matches!(self.step, Step::Vertex) { + self.selecting_object = false; + self.step = Step::FirstRay(point); + return CmdResult::NeedPoint; + } match self.step { Step::Vertex => { self.step = Step::FirstRay(point); @@ -229,15 +412,26 @@ impl CadCommand for AngularDimensionCommand { if point.distance_squared(vertex) <= 1.0e-24 { return CmdResult::NeedPoint; } + let first_local = self.plane.vector_to_local(first - vertex); + let second_local = self.plane.vector_to_local(point - vertex); + if (first_local.x * second_local.y - first_local.y * second_local.x).abs() + <= 1.0e-12 * first_local.length().max(second_local.length()).max(1.0) + { + return CmdResult::NeedPoint; + } self.step = Step::ArcPoint3 { vertex, first, second: point }; CmdResult::NeedPoint } - Step::CircleSecondRay { vertex, first, radius, source } => { - let Some(second) = project_to_radius(vertex, point, radius) else { + Step::CircleSecondRay { vertex, first, source } => { + if point.distance_squared(vertex) <= 1.0e-24 { return CmdResult::NeedPoint; - }; - self.source_handles = vec![source, source, source]; - self.step = Step::ArcPoint3 { vertex, first, second }; + } + self.source_refs = vec![ + Some(DimensionAssociationSource::inferred(source)), + Some(DimensionAssociationSource::inferred(source)), + None, + ]; + self.step = Step::ArcPoint3 { vertex, first, second: point }; CmdResult::NeedPoint } Step::SecondLine { .. } => CmdResult::NeedPoint, @@ -260,16 +454,21 @@ impl CadCommand for AngularDimensionCommand { } fn on_enter(&mut self) -> CmdResult { + if self.awaiting_quadrant { + self.awaiting_quadrant = false; + return CmdResult::NeedPoint; + } if self.awaiting_text { self.awaiting_text = false; return CmdResult::NeedPoint; } if self.awaiting_angle { self.awaiting_angle = false; + self.angle_origin = None; return CmdResult::NeedPoint; } if matches!(self.step, Step::Vertex) { - self.selecting_object = true; + self.selecting_object = !self.selecting_object; return CmdResult::NeedPoint; } CmdResult::Cancel @@ -287,8 +486,24 @@ impl CadCommand for AngularDimensionCommand { self.awaiting_text } + fn options(&self) -> Vec { + if self.placement_step() && !self.awaiting_text && !self.awaiting_angle && !self.awaiting_quadrant { + vec![ + CmdOption::new("Mtext", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + CmdOption::new("Quadrant", "QUADRANT"), + ] + } else { + Vec::new() + } + } + fn point_step_accepts_keywords(&self) -> bool { - self.placement_step() && !self.awaiting_text && !self.awaiting_angle + self.placement_step() + && !self.awaiting_text + && !self.awaiting_angle + && !self.awaiting_quadrant } fn on_text_input(&mut self, text: &str) -> Option { @@ -309,6 +524,7 @@ impl CadCommand for AngularDimensionCommand { crate::entities::common::parse_typed_angle(text.trim()) }; self.awaiting_angle = false; + self.angle_origin = None; return Some(CmdResult::NeedPoint); } if !self.placement_step() { @@ -322,22 +538,46 @@ impl CadCommand for AngularDimensionCommand { } "M" | "MTEXT" => { self.mtext_override = true; - self.awaiting_text = true; - Some(CmdResult::NeedPoint) + Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: 2.5, + }) } "A" | "ANGLE" => { self.awaiting_angle = true; + self.angle_origin = None; + Some(CmdResult::NeedPoint) + } + "Q" | "QUADRANT" => { + self.awaiting_quadrant = true; Some(CmdResult::NeedPoint) } - "Q" | "QUADRANT" => Some(CmdResult::NeedPoint), _ => None, } } + fn on_editor_text(&mut self, value: String) { + let value = value.trim(); + self.text_override = if value.is_empty() || value == "<>" { + None + } else { + Some(value.to_string()) + }; + } + + fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { + CmdResult::NeedPoint + } + fn needs_entity_pick(&self) -> bool { self.selecting_object } + fn entity_pick_accepts_points(&self) -> bool { + self.selecting_object && matches!(self.step, Step::Vertex) + } + fn entity_pick_highlights_hover(&self) -> bool { true } @@ -352,6 +592,10 @@ impl CadCommand for AngularDimensionCommand { fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { if handle.is_null() { + if matches!(self.step, Step::Vertex) { + self.selecting_object = false; + self.step = Step::FirstRay(point); + } return CmdResult::NeedPoint; } let Some(entity) = self.picked_entity.take() else { @@ -360,13 +604,42 @@ impl CadCommand for AngularDimensionCommand { let Some(curve) = picked_curve(&entity, point) else { return CmdResult::NeedPoint; }; + if !curve_is_coplanar(&curve, self.plane) { + return CmdResult::NeedPoint; + } match (&self.step, curve) { ( - Step::SecondLine { first_start, first_end, first_source }, - PickedCurve::Line(second_start, second_end), - ) if *first_source != handle => { - self.source_handles = vec![*first_source, *first_source, handle, handle]; + Step::SecondLine { + first_start, + first_end, + first_source, + first_refs, + }, + PickedCurve::Line { + start: second_start, + end: second_end, + refs: second_refs, + .. + }, + ) => { + if (*first_source == handle && *first_refs == second_refs) + || lines_parallel_in_plane( + self.plane, + *first_start, + *first_end, + second_start, + second_end, + ) + { + return CmdResult::NeedPoint; + } + self.source_refs = vec![ + Some(first_refs[0].bind(*first_source)), + Some(first_refs[1].bind(*first_source)), + Some(second_refs[0].bind(handle)), + Some(second_refs[1].bind(handle)), + ]; self.selecting_object = false; self.step = Step::ArcPoint2 { first_start: *first_start, @@ -376,29 +649,49 @@ impl CadCommand for AngularDimensionCommand { }; CmdResult::NeedPoint } - (Step::Vertex, PickedCurve::Line(first_start, first_end)) => { + ( + Step::Vertex, + PickedCurve::Line { + start: first_start, + end: first_end, + refs, + .. + }, + ) => { self.step = Step::SecondLine { first_start, first_end, first_source: handle, + first_refs: refs, }; CmdResult::NeedPoint } - (Step::Vertex, PickedCurve::Arc { center, first, second }) => { - self.source_handles = vec![handle, handle, handle]; + ( + Step::Vertex, + PickedCurve::Arc { + center, + first, + second, + refs, + .. + }, + ) => { + self.source_refs = refs + .into_iter() + .map(|reference| Some(reference.bind(handle))) + .collect(); self.selecting_object = false; self.step = Step::ArcPoint3 { vertex: center, first, second }; CmdResult::NeedPoint } - (Step::Vertex, PickedCurve::Circle { center, radius }) => { - let Some(first) = project_to_radius(center, point, radius) else { + (Step::Vertex, PickedCurve::Circle { center, .. }) => { + if point.distance_squared(center) <= 1.0e-24 { return CmdResult::NeedPoint; - }; + } self.selecting_object = false; self.step = Step::CircleSecondRay { vertex: center, - first, - radius, + first: point, source: handle, }; CmdResult::NeedPoint @@ -408,29 +701,63 @@ impl CadCommand for AngularDimensionCommand { } fn on_mouse_move(&mut self, point: DVec3) -> Option { + if self.awaiting_angle { + return self + .angle_origin + .map(|origin| preview_wire(vec![origin, point])); + } let points = match self.step { Step::Vertex | Step::SecondLine { .. } => return None, Step::FirstRay(vertex) => vec![vertex, point], Step::SecondRay { vertex, first } => vec![vertex, first, nan(), vertex, point], - Step::CircleSecondRay { vertex, first, radius, .. } => { - let second = project_to_radius(vertex, point, radius).unwrap_or(point); - vec![vertex, first, nan(), vertex, second] + Step::CircleSecondRay { vertex, first, .. } => { + vec![vertex, first, nan(), vertex, point] } Step::ArcPoint3 { vertex, first, second } => { + let vertex = self.plane.to_local(vertex); + let first = self.plane.to_local(first); + let second = self.plane.to_local(second); + let point = locked_arc_point( + vertex, + self.plane.to_local(point), + self.quadrant_lock, + ); angular_preview(vertex, first, second, point) + .into_iter() + .map(|value| if value.is_nan() { value } else { self.plane.to_world(value) }) + .collect() } Step::ArcPoint2 { first_start, first_end, second_start, second_end, - } => two_line_preview( - first_start, - first_end, - second_start, - second_end, - point, - ), + } => { + let first_start = self.plane.to_local(first_start); + let first_end = self.plane.to_local(first_end); + let second_start = self.plane.to_local(second_start); + let second_end = self.plane.to_local(second_end); + let point = self.plane.to_local(point); + let point = two_line_frame( + first_start, + first_end, + second_start, + second_end, + point, + ) + .map(|(vertex, _, _)| locked_arc_point(vertex, point, self.quadrant_lock)) + .unwrap_or(point); + two_line_preview( + first_start, + first_end, + second_start, + second_end, + point, + ) + .into_iter() + .map(|value| if value.is_nan() { value } else { self.plane.to_world(value) }) + .collect() + } }; Some(preview_wire(points)) } @@ -438,24 +765,43 @@ impl CadCommand for AngularDimensionCommand { fn picked_curve(entity: &EntityType, click: DVec3) -> Option { let point = |value: Vector3| DVec3::new(value.x, value.y, value.z); + let normal = |value: Vector3| DVec3::new(value.x, value.y, value.z); match entity { - EntityType::Line(line) => Some(PickedCurve::Line(point(line.start), point(line.end))), + EntityType::Line(line) => { + let start = point(line.start); + let end = point(line.end); + (start.distance_squared(end) > 1.0e-24).then_some(PickedCurve::Line { + start, + end, + refs: [SourceLocator::INFERRED; 2], + normal: None, + }) + } EntityType::Arc(arc) => Some(PickedCurve::Arc { center: point(arc.center_wcs()), first: point(arc.start_point_wcs()), second: point(arc.end_point_wcs()), + refs: [SourceLocator::INFERRED; 3], + normal: normal(arc.normal), }), EntityType::Circle(circle) => Some(PickedCurve::Circle { center: point(circle.center_wcs()), - radius: circle.radius, + normal: normal(circle.normal), }), EntityType::LwPolyline(polyline) => { let click = crate::scene::view::transform::wcs_point_to_ocs( (click.x, click.y, click.z), (polyline.normal.x, polyline.normal.y, polyline.normal.z), ); + let points: Vec<_> = polyline + .vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]) + .collect(); + let bulges: Vec<_> = polyline.vertices.iter().map(|vertex| vertex.bulge).collect(); let segment = nearest_segment_index( - polyline.vertices.iter().map(|vertex| [vertex.location.x, vertex.location.y]).collect(), + &points, + &bulges, polyline.is_closed, [click.0, click.1], )?; @@ -467,17 +813,24 @@ fn picked_curve(entity: &EntityType, click: DVec3) -> Option { first.bulge, polyline.elevation, polyline.normal, + segment, + polyline.vertices.len(), ) } EntityType::Polyline2D(polyline) => { - let vertices = crate::entities::polyline::drawn_vertices2d(polyline) - .unwrap_or_else(|| polyline.vertices.clone()); + let vertices = &polyline.vertices; let click = crate::scene::view::transform::wcs_point_to_ocs( (click.x, click.y, click.z), (polyline.normal.x, polyline.normal.y, polyline.normal.z), ); + let points: Vec<_> = vertices + .iter() + .map(|vertex| [vertex.location.x, vertex.location.y]) + .collect(); + let bulges: Vec<_> = vertices.iter().map(|vertex| vertex.bulge).collect(); let segment = nearest_segment_index( - vertices.iter().map(|vertex| [vertex.location.x, vertex.location.y]).collect(), + &points, + &bulges, polyline.is_closed(), [click.0, click.1], )?; @@ -489,27 +842,86 @@ fn picked_curve(entity: &EntityType, click: DVec3) -> Option { first.bulge, polyline.elevation, polyline.normal, + segment, + vertices.len(), ) } _ => None, } } -fn nearest_segment_index(points: Vec<[f64; 2]>, closed: bool, click: [f64; 2]) -> Option { +fn nearest_segment_index( + points: &[[f64; 2]], + bulges: &[f64], + closed: bool, + click: [f64; 2], +) -> Option { if points.len() < 2 { return None; } let count = if closed { points.len() } else { points.len() - 1 }; (0..count).min_by(|first, second| { - segment_distance_squared(points[*first], points[(*first + 1) % points.len()], click) - .total_cmp(&segment_distance_squared( + polyline_segment_distance_squared( + points[*first], + points[(*first + 1) % points.len()], + bulges.get(*first).copied().unwrap_or(0.0), + click, + ) + .total_cmp(&polyline_segment_distance_squared( points[*second], points[(*second + 1) % points.len()], + bulges.get(*second).copied().unwrap_or(0.0), click, )) }) } +fn bulge_center(first: [f64; 2], second: [f64; 2], bulge: f64) -> Option<[f64; 2]> { + if bulge.abs() <= 1.0e-12 { + return None; + } + let dx = second[0] - first[0]; + let dy = second[1] - first[1]; + let chord = dx.hypot(dy); + if chord <= 1.0e-12 { + return None; + } + let midpoint = [(first[0] + second[0]) * 0.5, (first[1] + second[1]) * 0.5]; + let offset = chord * (1.0 - bulge * bulge) / (4.0 * bulge); + Some([ + midpoint[0] - dy / chord * offset, + midpoint[1] + dx / chord * offset, + ]) +} + +fn polyline_segment_distance_squared( + first: [f64; 2], + second: [f64; 2], + bulge: f64, + point: [f64; 2], +) -> f64 { + let Some(center) = bulge_center(first, second, bulge) else { + return segment_distance_squared(first, second, point); + }; + let radius = (first[0] - center[0]).hypot(first[1] - center[1]); + let start = (first[1] - center[1]).atan2(first[0] - center[0]); + let target = (point[1] - center[1]).atan2(point[0] - center[0]); + let sweep = 4.0 * bulge.atan(); + let into = if sweep >= 0.0 { + (target - start).rem_euclid(std::f64::consts::TAU) + } else { + (start - target).rem_euclid(std::f64::consts::TAU) + }; + if into <= sweep.abs() + 1.0e-12 { + let radial = (point[0] - center[0]).hypot(point[1] - center[1]); + (radial - radius).powi(2) + } else { + ((point[0] - first[0]).powi(2) + (point[1] - first[1]).powi(2)).min( + (point[0] - second[0]).powi(2) + (point[1] - second[1]).powi(2), + ) + } +} + fn segment_distance_squared(first: [f64; 2], second: [f64; 2], point: [f64; 2]) -> f64 { let dx = second[0] - first[0]; let dy = second[1] - first[1]; @@ -530,6 +942,8 @@ fn polyline_segment( bulge: f64, elevation: f64, normal: Vector3, + segment: usize, + vertex_count: usize, ) -> Option { let to_world = |point: [f64; 2]| { let value = crate::scene::view::transform::ocs_point_to_wcs( @@ -540,31 +954,95 @@ fn polyline_segment( }; let first_world = to_world(first); let second_world = to_world(second); - if bulge.abs() <= 1.0e-12 { - return Some(PickedCurve::Line(first_world, second_world)); - } - let dx = second[0] - first[0]; - let dy = second[1] - first[1]; - let chord = (dx * dx + dy * dy).sqrt(); - if chord <= 1.0e-12 { + if first_world.distance_squared(second_world) <= 1.0e-24 { return None; } - let midpoint = [(first[0] + second[0]) * 0.5, (first[1] + second[1]) * 0.5]; - let center_offset = chord * (1.0 - bulge * bulge) / (4.0 * bulge); - let center = [ - midpoint[0] - dy / chord * center_offset, - midpoint[1] + dx / chord * center_offset, + let refs = [ + SourceLocator::explicit(segment as i32, 0.0), + SourceLocator::explicit(((segment + 1) % vertex_count) as i32, 0.0), ]; + let normal_world = DVec3::new(normal.x, normal.y, normal.z); + if bulge.abs() <= 1.0e-12 { + return Some(PickedCurve::Line { + start: first_world, + end: second_world, + refs, + normal: Some(normal_world), + }); + } + let center = bulge_center(first, second, bulge)?; Some(PickedCurve::Arc { center: to_world(center), first: first_world, second: second_world, + refs: [ + SourceLocator::explicit(-4, segment as f64), + refs[0], + refs[1], + ], + normal: normal_world, }) } -fn project_to_radius(center: DVec3, point: DVec3, radius: f64) -> Option { - let direction = (point - center).normalize_or_zero(); - (direction.length_squared() > 0.0).then_some(center + direction * radius) +fn curve_is_coplanar(curve: &PickedCurve, plane: WorkingPlane) -> bool { + let (points, normal): (Vec, Option) = match curve { + PickedCurve::Line { start, end, normal, .. } => (vec![*start, *end], *normal), + PickedCurve::Arc { center, first, second, normal, .. } => { + (vec![*center, *first, *second], Some(*normal)) + } + PickedCurve::Circle { center, normal } => (vec![*center], Some(*normal)), + }; + if let Some(normal) = normal { + let alignment = normal.normalize_or_zero().dot(plane.z).abs(); + if alignment < 1.0 - 1.0e-7 { + return false; + } + } + let local: Vec<_> = points.into_iter().map(|point| plane.to_local(point)).collect(); + let min_z = local.iter().map(|point| point.z).fold(f64::INFINITY, f64::min); + let max_z = local.iter().map(|point| point.z).fold(f64::NEG_INFINITY, f64::max); + let scale = local + .iter() + .map(|point| point.x.abs().max(point.y.abs()).max(1.0)) + .fold(1.0, f64::max); + max_z - min_z <= 1.0e-9 * scale +} + +fn lines_parallel_in_plane( + plane: WorkingPlane, + first_start: DVec3, + first_end: DVec3, + second_start: DVec3, + second_end: DVec3, +) -> bool { + let first = plane.vector_to_local(first_end - first_start); + let second = plane.vector_to_local(second_end - second_start); + let denominator = first.x * second.y - first.y * second.x; + denominator.abs() <= 1.0e-12 * first.length().max(second.length()).max(1.0) +} + +fn locked_arc_point( + vertex: DVec3, + point: DVec3, + frame: Option<(f64, f64)>, +) -> DVec3 { + let Some((start, end)) = frame else { return point }; + let radius = ((point.x - vertex.x).powi(2) + (point.y - vertex.y).powi(2)).sqrt(); + if radius <= 1.0e-12 { + return point; + } + let angle = (start + end) * 0.5; + DVec3::new( + vertex.x + angle.cos() * radius, + vertex.y + angle.sin() * radius, + vertex.z, + ) +} + +fn point_angle_in_frame(vertex: DVec3, point: DVec3, frame: (f64, f64)) -> bool { + let angle = (point.y - vertex.y).atan2(point.x - vertex.x); + let sweep = frame.1 - frame.0; + (angle - frame.0).rem_euclid(std::f64::consts::TAU) <= sweep + 1.0e-9 } fn two_line_frame( diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 853d4009..4918550f 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -6,6 +6,8 @@ use acadrust::objects::{ use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; +use crate::command::DimensionAssociationSource; + use super::{ChangeKind, Scene}; fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { @@ -23,6 +25,62 @@ fn ocs_point(x: f64, y: f64, elevation: f64, normal: Vector3) -> Vector3 { Vector3::new(point.0, point.1, point.2) } +fn bulge_center_world( + first: [f64; 2], + second: [f64; 2], + bulge: f64, + elevation: f64, + normal: Vector3, +) -> Option { + if bulge.abs() <= 1.0e-12 { + return None; + } + let dx = second[0] - first[0]; + let dy = second[1] - first[1]; + let chord = dx.hypot(dy); + if chord <= 1.0e-12 { + return None; + } + let midpoint = [(first[0] + second[0]) * 0.5, (first[1] + second[1]) * 0.5]; + let offset = chord * (1.0 - bulge * bulge) / (4.0 * bulge); + Some(ocs_point( + midpoint[0] - dy / chord * offset, + midpoint[1] + dx / chord * offset, + elevation, + normal, + )) +} + +fn polyline_arc_center(entity: &EntityType, segment: usize) -> Option { + match entity { + EntityType::LwPolyline(polyline) => { + let count = polyline.vertices.len(); + let first = *polyline.vertices.get(segment)?; + let second = *polyline.vertices.get((segment + 1) % count)?; + bulge_center_world( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + ) + } + EntityType::Polyline2D(polyline) => { + let count = polyline.vertices.len(); + let first = polyline.vertices.get(segment)?; + let second = polyline.vertices.get((segment + 1) % count)?; + bulge_center_world( + [first.location.x, first.location.y], + [second.location.x, second.location.y], + first.bulge, + polyline.elevation, + polyline.normal, + ) + } + _ => None, + } +} + fn source_points(entity: &EntityType) -> Vec { match entity { EntityType::Line(line) => vec![line.start, line.end], @@ -101,6 +159,10 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { let source = *reference.xrefs.first()?; let entity = scene.document.get_entity(source)?; + if reference.main_gs_marker == -4 { + let segment = reference.osnap_distance.round().max(0.0) as usize; + return polyline_arc_center(entity, segment); + } if reference.main_gs_marker == -3 { return match entity { EntityType::Circle(circle) => Some(circle.center_wcs()), @@ -167,6 +229,20 @@ impl Scene { &mut self, dimension: Handle, sources: Vec>, + ) { + self.attach_dimension_association_sources( + dimension, + sources + .into_iter() + .map(|source| source.map(DimensionAssociationSource::inferred)) + .collect(), + ); + } + + pub(crate) fn attach_dimension_association_sources( + &mut self, + dimension: Handle, + sources: Vec>, ) { let Some(EntityType::Dimension(dimension_entity)) = self.document.get_entity(dimension) @@ -182,9 +258,14 @@ impl Scene { .enumerate() .map(|(index, point)| { let source = sources.get(index).copied().flatten()?; - let entity = self.document.get_entity(source)?; - source_reference(entity, *point) - .map(|(marker, parameter)| (source, marker, parameter)) + let (marker, parameter) = match source.marker { + Some(marker) => (marker, source.parameter), + None => { + let entity = self.document.get_entity(source.handle)?; + source_reference(entity, *point)? + } + }; + Some((source.handle, marker, parameter)) }) .collect(); if resolved.iter().all(Option::is_none) { @@ -229,7 +310,7 @@ impl Scene { .insert(association_handle, ObjectType::Associative(object)); let mut reactor_targets = vec![dimension]; - reactor_targets.extend(sources.into_iter().flatten()); + reactor_targets.extend(sources.into_iter().flatten().map(|source| source.handle)); reactor_targets.sort_by_key(|handle| handle.value()); reactor_targets.dedup(); for handle in reactor_targets { From 6bbef80a2687d6a0da3d02f32e0a2db13f219f4d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 12:23:29 +0300 Subject: [PATCH 50/98] Complete angular dimension layout and fitting --- src/entities/dimension.rs | 234 +++++++++++++++++++++++++++++++++++--- 1 file changed, 216 insertions(+), 18 deletions(-) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 0e0d8123..0314a2f7 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -1095,6 +1095,14 @@ fn dimension_line_grip_position(dim: &Dimension) -> Option { } fn above_dimension_text_position(dim: &Dimension) -> Option { + if let Some((vertex, start, end, radius)) = angular_dimension_frame(dim) { + let angle = (start + end) * 0.5; + let direction = DVec3::new(angle.cos() as f64, angle.sin() as f64, 0.0); + return Some( + DVec3::new(vertex.x as f64, vertex.y as f64, vertex.z as f64) + + direction * (radius as f64 + 1.0), + ); + } let center = dimension_line_grip_position(dim)?; let (ax, ay) = match dim { Dimension::Linear(d) => (d.rotation.cos(), d.rotation.sin()), @@ -2524,7 +2532,7 @@ use acadrust::{CadDocument, EntityType, Handle}; use crate::scene::convert::tess_util::aci_to_rgba; use crate::scene::convert::tessellate::{ - add_polyline, add_segment, append_arrow, arrow_from_block_with_deferred_hatch, + add_segment, append_arrow, arrow_from_block_with_deferred_hatch, normalized_or, ArrowKind, DimGeom, }; use crate::scene::model::wire_model::{SnapHint, WireModel}; @@ -4056,6 +4064,43 @@ fn two_line_angle_frame( Some((vertex, start, end)) } +fn angular_dimension_frame(dim: &Dimension) -> Option<(Vec3, f32, f32, f32)> { + let (vertex, start, end, arc_point) = match dim { + Dimension::Angular2Ln(value) => { + let first_start = vec3_local(value.first_point); + let first_end = vec3_local(value.second_point); + let second_start = vec3_local(value.angle_vertex); + let second_end = vec3_local(value.definition_point); + let stored_arc = if value.dimension_arc.length_squared() > 1.0e-18 { + value.dimension_arc + } else { + value.base.definition_point + }; + let arc_point = vec3_local(stored_arc); + let (vertex, start, end) = two_line_angle_frame( + first_start, + first_end, + second_start, + second_end, + arc_point, + )?; + (vertex, start, end, arc_point) + } + Dimension::Angular3Pt(value) => { + let vertex = vec3_local(value.angle_vertex); + let first = vec3_local(value.first_point); + let second = vec3_local(value.second_point); + let arc_point = vec3_local(value.definition_point); + let (vertex, start, end) = + two_line_angle_frame(vertex, first, vertex, second, arc_point)?; + (vertex, start, end, arc_point) + } + _ => return None, + }; + let radius = vertex.distance(arc_point); + (radius > 1.0e-6).then_some((vertex, start, end, radius)) +} + fn append_angular_dimension( g: &mut DimGeom, vertex: Vec3, @@ -4121,11 +4166,22 @@ fn append_angular_dimension( delta = end - start; } - let arc_extension = if params.ticks && radius > 1.0e-6 { - params.dimdle / radius + let arc_length = radius * delta.abs(); + let arrows_outside = if params.ticks || params.arrow_len <= 1.0e-6 { + false + } else if arc_length < params.arrow_len * 2.0 { + true + } else if arc_length < params.text_width + params.arrow_len * 2.0 { + match params.dimatfit { + 0 | 1 => true, + 2 => false, + _ => params.text_width <= arc_length, + } } else { - 0.0 + false }; + let draw_inside_line = !arrows_outside || params.dimtofl; + let arc_extension = if params.ticks { params.dimdle / radius } else { 0.0 }; let direction = delta.signum(); let draw_start = start - direction * arc_extension; let draw_delta = delta + direction * arc_extension * 2.0; @@ -4133,34 +4189,119 @@ fn append_angular_dimension( let mut arc_pts = Vec::with_capacity((steps + 1) as usize); for i in 0..=steps { let t = i as f32 / steps as f32; - let a = draw_start + draw_delta * t; - arc_pts.push(vertex + Vec3::new(a.cos() * radius, a.sin() * radius, 0.0)); + let angle = draw_start + draw_delta * t; + arc_pts.push(vertex + Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0)); } - let midpoint = arc_pts.len() / 2; - if !suppress.dim1 && midpoint > 0 { - add_polyline(&mut g.dim_lines, &arc_pts[..=midpoint]); - } - if !suppress.dim2 && midpoint + 1 < arc_pts.len() { - add_polyline(&mut g.dim_lines, &arc_pts[midpoint..]); + if draw_inside_line { + for index in 0..steps as usize { + let t = (index as f32 + 0.5) / steps as f32; + if (t < 0.5 && suppress.dim1) || (t >= 0.5 && suppress.dim2) { + continue; + } + let a = arc_pts[index]; + let b = arc_pts[index + 1]; + let hidden_by_text = params.text_break.is_some_and(|(center, half_width, half_height)| { + let angle = draw_start + draw_delta * t; + let radial = Vec3::new(angle.cos(), angle.sin(), 0.0); + let tangent = Vec3::new(-angle.sin(), angle.cos(), 0.0); + let offset = center - (a + b) * 0.5; + offset.dot(tangent).abs() <= half_width + && offset.dot(radial).abs() <= half_height + }); + if !hidden_by_text { + add_segment(&mut g.dim_lines, a, b); + } + } } - if arc_pts.len() >= 2 { + if arrows_outside && !params.dimsoxd { + let stub_angle = (params.arrow_len * 2.0 / radius).min(std::f32::consts::FRAC_PI_2); + if !suppress.dim1 { + append_sampled_arc( + &mut g.dim_lines, + vertex, + radius, + start - direction * stub_angle, + start, + ); + } + if !suppress.dim2 { + append_sampled_arc( + &mut g.dim_lines, + vertex, + radius, + end, + end + direction * stub_angle, + ); + } + } + + if let Some((text_center, half_width, _)) = params.text_break { + let text_angle = (text_center.y - vertex.y).atan2(text_center.x - vertex.x); + let into = (text_angle - start).rem_euclid(std::f32::consts::TAU); + if into > delta.abs() + 1.0e-6 { + let back_to_start = (start - text_angle).rem_euclid(std::f32::consts::TAU); + let forward_from_end = (text_angle - end).rem_euclid(std::f32::consts::TAU); + let text_gap = (half_width / radius).min(std::f32::consts::FRAC_PI_2); + if back_to_start <= forward_from_end && !suppress.dim1 { + append_sampled_arc( + &mut g.dim_lines, + vertex, + radius, + text_angle + text_gap, + start, + ); + } else if !suppress.dim2 { + append_sampled_arc( + &mut g.dim_lines, + vertex, + radius, + end, + text_angle - text_gap, + ); + } + } + } + + let start_tangent = Vec3::new(-start.sin(), start.cos(), 0.0) * direction; + let end_tangent = Vec3::new(-end.sin(), end.cos(), 0.0) * direction; + let draw_arrows = !arrows_outside || !params.dimsoxd; + if draw_arrows && !suppress.dim1 { append_arrow( g, - arc_pts[0], - normalized_or(arc_pts[1] - arc_pts[0], Vec3::X), + arc_start, + if arrows_outside { -start_tangent } else { start_tangent }, arrow1, ); - let n = arc_pts.len(); + } + if draw_arrows && !suppress.dim2 { append_arrow( g, - arc_pts[n - 1], - normalized_or(arc_pts[n - 2] - arc_pts[n - 1], Vec3::X), + arc_end, + if arrows_outside { end_tangent } else { -end_tangent }, arrow2, ); } } +fn append_sampled_arc( + lines: &mut Vec<[f32; 3]>, + vertex: Vec3, + radius: f32, + start: f32, + end: f32, +) { + let steps = angular_arc_steps(end - start); + let mut previous = vertex + Vec3::new(start.cos() * radius, start.sin() * radius, 0.0); + for index in 1..=steps { + let t = index as f32 / steps as f32; + let angle = start + (end - start) * t; + let next = vertex + Vec3::new(angle.cos() * radius, angle.sin() * radius, 0.0); + add_segment(lines, previous, next); + previous = next; + } +} + fn angular_arc_steps(sweep: f32) -> u32 { ((sweep.abs() / std::f32::consts::PI * 90.0).ceil() as u32).clamp(8, 720) } @@ -4422,6 +4563,32 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool let Some(style) = style else { return false; }; + if let Some((vertex, start, end, radius)) = angular_dimension_frame(dim) { + if dim.base().text_user_positioned { + let text = vec3_local(dim.base().text_middle_point); + let angle = (text.y - vertex.y).atan2(text.x - vertex.x); + return (angle - start).rem_euclid(std::f32::consts::TAU) + > end - start + 1.0e-6; + } + if style.dimtix { + return false; + } + let scale = if style.dimscale > 1e-9 { style.dimscale } else { 1.0 }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let span = radius as f64 * (end - start).abs() as f64; + let insufficient = text_width + arrow * 2.0 > span; + return insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > span, + _ => text_width > span, + }; + } let (first, second, axis) = match dim { Dimension::Linear(d) => ( d.first_point, @@ -4472,6 +4639,11 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let dy = d.second_point.y - d.first_point.y; dy.atan2(dx) } + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => angular_dimension_frame(dim) + .map(|(_, start, end, _)| { + ((start + end) * 0.5 + std::f32::consts::FRAC_PI_2) as f64 + }) + .unwrap_or(0.0), _ => 0.0, }; // Clamp to (-π/2, π/2] so text never appears upside-down. @@ -5245,6 +5417,32 @@ fn dimension_text_pos_f64( dimtad, ) } + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => { + let Some((vertex, start, end, radius)) = angular_dimension_frame(dim) else { + return base.text_middle_point; + }; + let span = radius as f64 * (end - start).abs() as f64; + let insufficient = text_w + arrow * 2.0 > span; + let move_outside = !dimtix + && insufficient + && match dimatfit { + 0 | 2 => true, + 1 | 3 => text_w > span, + _ => text_w > span, + }; + let angle = if move_outside { + end as f64 + (text_w * 0.5 + dimgap + arrow) / (radius as f64).max(1.0e-12) + } else { + ((start + end) * 0.5) as f64 + }; + let radial_offset = if dimtad == 4 { -perp_off } else { perp_off }; + let text_radius = (radius as f64 + radial_offset).max(0.0); + Vector3::new( + vertex.x as f64 + angle.cos() * text_radius, + vertex.y as f64 + angle.sin() * text_radius, + vertex.z as f64, + ) + } _ => { // Non-linear (radius / diameter / angular / ordinate): lift the // natural mid point straight up by the style offset. A user-dragged From d33c52a0b81fefc6e369d17be34ce49ff4ef95ca Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:56:34 +0300 Subject: [PATCH 51/98] Add associative radius dimension workflow --- Cargo.lock | 2 +- Cargo.toml | 4 +- src/modules/annotate/radius_dim.rs | 140 ++++++++---- src/scene/dimension_assoc.rs | 345 +++++++++++++++++++++++++++++ 4 files changed, 448 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 808da012..78687c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b2ae66#5b2ae66d0bd7b0da2d13392d3c3332f8a39caa1f" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=8c2cc9a#8c2cc9ac6de7ff8f129c0522fdc50216970faa1d" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index dc49bfb1..663dcb73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "8c2cc9a", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "8c2cc9a" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index 6bb9ec58..8aa5ec5f 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -1,5 +1,5 @@ use acadrust::entities::{Dimension, DimensionRadius}; -use acadrust::types::Vector3; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use crate::command::{CadCommand, CmdResult, WorkingPlane}; @@ -20,14 +20,12 @@ pub fn tool() -> ToolDef { } enum Step { - CenterPoint, - RadiusPoint(DVec3), - TextPoint { center: DVec3, point: DVec3 }, + SelectObject, + DimLine(crate::scene::dimension_assoc::RadialSourceGeometry), } pub struct RadiusDimensionCommand { step: Step, - plane: WorkingPlane, /// Optional text that replaces the measured value (None = measurement). text_override: Option, /// True while the next typed line is captured as the text override. @@ -36,24 +34,28 @@ pub struct RadiusDimensionCommand { text_angle: Option, /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + picked_entity: Option, + source_handle: Option, + mtext_override: bool, } impl RadiusDimensionCommand { pub fn new() -> Self { Self { - step: Step::CenterPoint, - plane: WorkingPlane::default(), + step: Step::SelectObject, text_override: None, awaiting_text: false, text_angle: None, awaiting_angle: false, + picked_entity: None, + source_handle: None, + mtext_override: false, } } } impl CadCommand for RadiusDimensionCommand { - fn set_working_plane(&mut self, plane: WorkingPlane) { - self.plane = plane; + fn set_working_plane(&mut self, _plane: WorkingPlane) { } fn name(&self) -> &'static str { @@ -62,48 +64,57 @@ impl CadCommand for RadiusDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMRADIUS Enter dimension text (blank = measured value):").into_owned(); + return if self.mtext_override { + t!("DIMRADIUS Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMRADIUS Enter dimension text (blank = measured value):").into_owned() + }; } if self.awaiting_angle { return t!("DIMRADIUS Specify text angle (degrees):").into_owned(); } match self.step { - Step::CenterPoint => t!("DIMRADIUS Specify center point:").into_owned(), - Step::RadiusPoint(_) => t!("DIMRADIUS Specify radius point:").into_owned(), - Step::TextPoint { .. } => { - t!("DIMRADIUS Specify dimension line location [Text/Angle]:").into_owned() + Step::SelectObject => t!("DIMRADIUS Select arc, circle, or polyline arc:").into_owned(), + Step::DimLine(_) => { + t!("DIMRADIUS Specify dimension line location [Mtext/Text/Angle]:").into_owned() } } } fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { - Step::CenterPoint => { - self.step = Step::RadiusPoint(pt); - CmdResult::NeedPoint - } - Step::RadiusPoint(center) => { - self.step = Step::TextPoint { center, point: pt }; - CmdResult::NeedPoint - } - Step::TextPoint { center, point } => { - let center = self.plane.to_local(center); - let point = self.plane.to_local(point); - let pt = self.plane.to_local(pt); + Step::SelectObject => CmdResult::NeedPoint, + Step::DimLine(source) => { + let source_plane = WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ); + let center_world = dvec(source.center_world()); + let point_world = dvec(source.chord_at(pt.to_array())); + let center = source_plane.to_local(center_world); + let point = source_plane.to_local(point_world); + let pt = source_plane.to_local(pt); let mut dim = DimensionRadius::new(v3(center), v3(point)); dim.base.definition_point = v3(point); dim.base.text_middle_point = v3(pt); dim.base.insertion_point = v3(pt); + dim.base.text_user_positioned = true; dim.leader_length = point.distance(pt); dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); // An explicit text angle overrides the default rotation. if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Radius(dim), - ))) + CmdResult::CommitDimension { + entity: source_plane.place_entity(EntityType::Dimension(Dimension::Radius(dim))), + source: self.source_handle, + } } } } @@ -162,8 +173,17 @@ impl CadCommand for RadiusDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } + if !matches!(self.step, Step::DimLine(_)) { + return None; + } match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; self.awaiting_text = true; Some(CmdResult::NeedPoint) } @@ -175,18 +195,54 @@ impl CadCommand for RadiusDimensionCommand { } } + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::SelectObject) + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(source) = crate::scene::dimension_assoc::radial_source_at( + &entity, + Vector3::new(point.x, point.y, point.z), + ) else { + return CmdResult::NeedPoint; + }; + if !source.radius.is_finite() || source.radius <= 1e-12 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.step = Step::DimLine(source); + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); match self.step { - Step::CenterPoint => None, - Step::RadiusPoint(center) => Some(preview_wire(vec![center.as_vec3(), pt])), - Step::TextPoint { center, point } => Some(preview_wire(vec![ - center.as_vec3(), - point.as_vec3(), + Step::SelectObject => None, + Step::DimLine(source) => { + let center = dvec(source.center_world()).as_vec3(); + let point = dvec(source.chord_at(pt.to_array())).as_vec3(); + Some(preview_wire(vec![ + center, + point, Vec3::new(f32::NAN, f32::NAN, f32::NAN), - point.as_vec3(), - pt, - ])), + point, + pt.as_vec3(), + ])) + } } } } @@ -195,6 +251,10 @@ fn v3(pt: DVec3) -> Vector3 { Vector3::new(pt.x, pt.y, pt.z) } +fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + fn preview_wire(points: Vec) -> WireModel { WireModel { point_marker: None, diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 2814695e..d8168fa2 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -8,10 +8,193 @@ use acadrust::EntityType; use cadkernel::geom2d::{ closest_point, Circle as KernelCircle, Curve as KernelCurve, }; +use cadkernel::space::Plane; use std::f64::consts::TAU; use super::{ChangeKind, Scene}; +#[derive(Clone, Copy, Debug)] +pub(crate) struct RadialSourceGeometry { + pub plane: Plane, + pub center: [f64; 2], + pub radius: f64, + pub start_angle: f64, + pub sweep: f64, + pub limited: bool, + pub marker: i32, +} + +impl RadialSourceGeometry { + pub(crate) fn center_world(self) -> Vector3 { + vector3(self.plane.point_at(self.center)) + } + + pub(crate) fn point_at_angle(self, angle: f64) -> Vector3 { + vector3(self.plane.point_at([ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ])) + } + + pub(crate) fn angle_at(self, point: DPoint) -> f64 { + let projected = self.plane.project(point).unwrap_or(self.center); + (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]) + } + + pub(crate) fn chord_at(self, point: DPoint) -> Vector3 { + self.point_at_angle(self.angle_at(point)) + } + + fn contains_angle(self, angle: f64) -> bool { + if !self.limited { + return true; + } + signed_progress(self.start_angle, angle, self.sweep) + .is_some_and(|progress| progress <= self.sweep.abs() + 1e-10) + } + + fn distance_squared_to(self, point: DPoint) -> f64 { + let Some(projected) = self.plane.project(point) else { + return f64::INFINITY; + }; + let angle = (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]); + let radial = ((projected[0] - self.center[0]).hypot(projected[1] - self.center[1]) + - self.radius) + .abs(); + let candidate = if self.contains_angle(angle) { + [ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ] + } else { + let start = [ + self.center[0] + self.radius * self.start_angle.cos(), + self.center[1] + self.radius * self.start_angle.sin(), + ]; + let end_angle = self.start_angle + self.sweep; + let end = [ + self.center[0] + self.radius * end_angle.cos(), + self.center[1] + self.radius * end_angle.sin(), + ]; + let start_distance = squared_2d(start, projected); + let end_distance = squared_2d(end, projected); + if start_distance <= end_distance { start } else { end } + }; + let planar = if self.contains_angle(angle) { + radial * radial + } else { + squared_2d(candidate, projected) + }; + let world = self.plane.point_at(projected); + let dx = world[0] - point[0]; + let dy = world[1] - point[1]; + let dz = world[2] - point[2]; + planar + dx * dx + dy * dy + dz * dz + } +} + +type DPoint = [f64; 3]; + +fn vector3(point: DPoint) -> Vector3 { + Vector3::new(point[0], point[1], point[2]) +} + +fn dpoint(point: Vector3) -> DPoint { + [point.x, point.y, point.z] +} + +fn squared_2d(first: [f64; 2], second: [f64; 2]) -> f64 { + let dx = first[0] - second[0]; + let dy = first[1] - second[1]; + dx * dx + dy * dy +} + +fn signed_progress(start: f64, angle: f64, sweep: f64) -> Option { + if !start.is_finite() || !angle.is_finite() || !sweep.is_finite() || sweep.abs() <= 1e-12 { + return None; + } + let progress = if sweep > 0.0 { + (angle - start).rem_euclid(TAU) + } else { + (start - angle).rem_euclid(TAU) + }; + Some(progress) +} + +fn radial_candidates(entity: &EntityType) -> Vec { + let Some(planar) = crate::entities::curve::entity_curve(entity) else { + return Vec::new(); + }; + match planar.curve { + KernelCurve::Circle(circle) => vec![RadialSourceGeometry { + plane: planar.plane, + center: circle.centre, + radius: circle.radius, + start_angle: 0.0, + sweep: TAU, + limited: false, + marker: 0, + }], + KernelCurve::Arc(arc) => vec![RadialSourceGeometry { + plane: planar.plane, + center: arc.centre, + radius: arc.radius, + start_angle: arc.start_angle, + sweep: arc.sweep(), + limited: true, + marker: 0, + }], + KernelCurve::Polyline(polyline) => (0..polyline.vertices.len()) + .filter_map(|index| { + let arc = polyline.segment_arc(index)?; + Some(RadialSourceGeometry { + plane: planar.plane, + center: arc.center, + radius: arc.radius, + start_angle: arc.start_angle, + sweep: arc.sweep, + limited: true, + marker: index as i32, + }) + }) + .collect(), + _ => Vec::new(), + } +} + +pub(crate) fn radial_source_at( + entity: &EntityType, + point: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + first + .distance_squared_to(dpoint(point)) + .total_cmp(&second.distance_squared_to(dpoint(point))) + }) +} + +fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option { + radial_candidates(entity) + .into_iter() + .find(|candidate| candidate.marker == marker) +} + +fn radial_source_matching( + entity: &EntityType, + center: Vector3, + radius: f64, + chord: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + let score = |candidate: &RadialSourceGeometry| { + point_distance_squared(candidate.center_world(), center) + + (candidate.radius - radius).powi(2) + + candidate.distance_squared_to(dpoint(chord)) * 1e-6 + }; + score(first).total_cmp(&score(second)) + }) +} + fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { let dx = first.x - second.x; let dy = first.y - second.y; @@ -172,6 +355,70 @@ pub(crate) fn dimension_is_associative( }) } +pub(crate) fn radial_extension_points( + document: &acadrust::CadDocument, + dimension: Handle, + gap: f64, +) -> Option> { + let EntityType::Dimension(Dimension::Radius(radius_dimension)) = + document.get_entity(dimension)? + else { + return None; + }; + let association = document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + (association.dimension == dimension).then_some(association) + })?; + let reference = association.references[0].first()?; + let source = document.get_entity(*reference.xrefs.first()?)?; + let radial = radial_source_for_marker(source, reference.main_gs_marker)?; + if !radial.limited || radial.radius <= 1e-12 { + return None; + } + let target = radial.angle_at(dpoint(radius_dimension.definition_point)); + if radial.contains_angle(target) { + return None; + } + + let end = radial.start_angle + radial.sweep; + let direction = radial.sweep.signum(); + let from_start = if direction > 0.0 { + (radial.start_angle - target).rem_euclid(TAU) + } else { + (target - radial.start_angle).rem_euclid(TAU) + }; + let from_end = if direction > 0.0 { + (target - end).rem_euclid(TAU) + } else { + (end - target).rem_euclid(TAU) + }; + let (origin, extension_direction, span) = if from_start <= from_end { + (radial.start_angle, -direction, from_start) + } else { + (end, direction, from_end) + }; + let gap_angle = gap.max(0.0) / radial.radius; + if span <= gap_angle + 1e-10 { + return None; + } + let visible_span = span - gap_angle; + let first_angle = origin + extension_direction * gap_angle; + let segments = (visible_span / (5.0_f64.to_radians())).ceil().max(1.0) as usize; + Some( + (0..=segments) + .map(|index| { + let fraction = index as f64 / segments as f64; + radial.point_at_angle(first_angle + extension_direction * visible_span * fraction) + }) + .collect(), + ) +} + impl Scene { pub(crate) fn attach_dimension_association( &mut self, @@ -182,6 +429,41 @@ impl Scene { else { return; }; + if let Dimension::Radius(radius_dimension) = entity { + let Some(source) = sources.into_iter().flatten().next() else { + return; + }; + let Some(source_entity) = self.document.get_entity(source) else { + return; + }; + let measured_radius = radius_dimension.measurement(); + let Some(radial) = radial_source_matching( + source_entity, + radius_dimension.angle_vertex, + measured_radius, + radius_dimension.definition_point, + ) else { + return; + }; + let angle = radial.angle_at(dpoint(radius_dimension.definition_point)); + let reference = AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 10, + xrefs: vec![source], + main_subent_type: 1, + main_gs_marker: radial.marker, + osnap_distance: angle, + osnap_point: radius_dimension.definition_point, + ..AssocDimensionReference::default() + }; + self.store_dimension_association( + dimension, + [vec![reference], Vec::new(), Vec::new(), Vec::new()], + 1, + [Some(source), None], + ); + return; + } let Some(source_data) = dimension_points(entity) else { return; }; @@ -235,6 +517,16 @@ impl Scene { } } + self.store_dimension_association(dimension, references, associativity, sources); + } + + fn store_dimension_association( + &mut self, + dimension: Handle, + references: [Vec; 4], + associativity: i32, + sources: [Option; 2], + ) { let association_handle = self.document.allocate_handle(); let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); object.handle = association_handle; @@ -270,6 +562,34 @@ impl Scene { else { return [None, None]; }; + if let Dimension::Radius(radius) = entity { + let measurement = radius.measurement(); + let source = self + .document + .entities() + .filter(|candidate| candidate.common().handle != dimension) + .filter_map(|candidate| { + let radial = radial_source_matching( + candidate, + radius.angle_vertex, + measurement, + radius.definition_point, + )?; + let center_error = point_distance_squared( + radial.center_world(), + radius.angle_vertex, + ); + let radius_error = (radial.radius - measurement).powi(2); + let tolerance = measurement.abs().max(1.0) * 1e-9; + (center_error + radius_error <= tolerance * tolerance).then_some(( + center_error + radius_error, + candidate.common().handle, + )) + }) + .min_by(|first, second| first.0.total_cmp(&second.0)) + .map(|(_, handle)| handle); + return [source, None]; + } let Some(points) = dimension_points(entity) else { return [None, None]; }; @@ -318,6 +638,12 @@ impl Scene { let mut refreshed = Vec::new(); for association in associations { + let radial_source = association.references[0].first().and_then(|reference| { + let source = *reference.xrefs.first()?; + let entity = self.document.get_entity(source)?; + let radial = radial_source_for_marker(entity, reference.main_gs_marker)?; + Some((radial, reference.osnap_distance)) + }); let first = association.references[0] .first() .and_then(|reference| resolve_reference(self, reference)); @@ -353,6 +679,25 @@ impl Scene { aligned.base.actual_measurement = aligned.measurement(); aligned.base.definition_point = aligned.definition_point; } + Dimension::Radius(radius) => { + let Some((radial, angle)) = radial_source else { + continue; + }; + let old_chord = radius.definition_point; + let new_center = radial.center_world(); + let new_chord = radial.point_at_angle(angle); + let delta = Vector3::new( + new_chord.x - old_chord.x, + new_chord.y - old_chord.y, + new_chord.z - old_chord.z, + ); + radius.angle_vertex = new_center; + radius.definition_point = new_chord; + radius.base.definition_point = new_chord; + radius.base.text_middle_point = radius.base.text_middle_point + delta; + radius.base.insertion_point = radius.base.insertion_point + delta; + radius.base.actual_measurement = radius.measurement(); + } _ => continue, } refreshed.push((association.dimension, ChangeKind::Modified)); From 205893c784ad0b0430a00d149fe9e5eab5c3c25c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 13:56:35 +0300 Subject: [PATCH 52/98] Align radius dimension properties and graphics --- src/entities/dim_override.rs | 25 ++++ src/entities/dimension.rs | 228 +++++++++++++++++++++++++++++++---- 2 files changed, 227 insertions(+), 26 deletions(-) diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 13ce51b8..868bbab6 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -352,6 +352,7 @@ fn inherited_real(doc: &CadDocument, handle: Handle, code: i16) -> f64 { }; match code { DIMGAP => style.dimgap, + DIMCEN => style.dimcen, DIMTP => style.dimtp, DIMTM => style.dimtm, _ => 0.0, @@ -390,6 +391,30 @@ pub fn set_property( value: &str, ) -> bool { let trimmed = value.trim(); + if field == "dim_center_type" { + let current = inherited_real(doc, handle, DIMCEN); + let size = current.abs().max(0.01); + let value = match trimmed.to_ascii_lowercase().as_str() { + "none" => 0.0, + "mark" => size, + "lines" => -size, + _ => return false, + }; + set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); + return true; + } + if field == "dim_center_size" { + let Ok(size) = trimmed.parse::() else { + return false; + }; + if !size.is_finite() || size < 0.0 { + return false; + } + let current = inherited_real(doc, handle, DIMCEN); + let value = if current < 0.0 { -size } else { size }; + set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); + return true; + } let handle_field = match field { "dim_arrowhead_1" => Some(DIMBLK1), "dim_arrowhead_2" => Some(DIMBLK2), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index ae303266..3639654c 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -86,6 +86,23 @@ fn base_props(base: &DimensionBase) -> Vec Vec { + if let Dimension::Radius(radius) = dim { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![ + Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: PropValue::PlainText(radius.base.style_name.clone()), + }, + edit( + t!("Leader Length").as_ref(), + "leader_length", + radius.leader_length, + ), + ], + }]; + } let compact_linear = match dim { Dimension::Linear(d) => Some(( &d.base, @@ -1636,7 +1653,23 @@ pub fn style_sections( let linetype = |code, inherited| { linetype_name(document, ov::handle(data, code).unwrap_or(inherited)) }; - let arrow_1 = arrow_name(ov::DIMBLK1, s.dimblk1, &s.dimblk1_name); + let arrow_1 = if matches!(dimension, Dimension::Radius(_)) { + let inherited = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; + let inherited_name = if s.dimblk1.is_null() { + &s.dimblk_name + } else { + &s.dimblk1_name + }; + block_name( + document, + ov::handle(data, ov::DIMBLK1) + .or_else(|| ov::handle(data, ov::DIMBLK)) + .unwrap_or(inherited), + inherited_name, + ) + } else { + arrow_name(ov::DIMBLK1, s.dimblk1, &s.dimblk1_name) + }; let arrow_2 = arrow_name(ov::DIMBLK2, s.dimblk2, &s.dimblk2_name); for current in [&arrow_1, &arrow_2] { if !arrow_options.contains(current) { @@ -1682,7 +1715,7 @@ pub fn style_sections( _ => choice_value("None", &["None", "Background", "Color"]), }; - vec![ + let mut sections = vec![ PropSection { title: t!("Lines & Arrows").into_owned(), props: vec![ @@ -2312,7 +2345,49 @@ pub fn style_sections( ), ], }, - ] + ]; + + if matches!(dimension, Dimension::Radius(_)) { + let dimcen = real(ov::DIMCEN, s.dimcen); + let center_type = if dimcen > 1e-12 { + "Mark" + } else if dimcen < -1e-12 { + "Lines" + } else { + "None" + }; + if let Some(lines) = sections + .iter_mut() + .find(|section| section.title == t!("Lines & Arrows").as_ref()) + { + const RADIUS_LINE_FIELDS: &[&str] = &[ + "dim_arrowhead_1", + "dim_arrow_size", + "dim_line_lineweight", + "dim_line_1", + "dim_line_color", + "dim_linetype", + ]; + lines + .props + .retain(|property| RADIUS_LINE_FIELDS.contains(&property.field)); + lines.props.push(choice( + t!("Center mark").as_ref(), + "dim_center_type", + center_type, + &["None", "Mark", "Lines"], + true, + )); + lines.props.push(number( + t!("Center size").as_ref(), + "dim_center_size", + dimcen.abs(), + center_type != "None", + )); + } + } + + sections } fn property(label: &str, field: &'static str, value: PropValue) -> Property { @@ -2835,7 +2910,16 @@ fn tessellate_dimension_inner( }; (t.clone(), t) } else if let Some(s) = style { - if dimsah { + if matches!(dim, Dimension::Radius(_)) { + let handle = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; + let arrow = arrow_from_block_with_deferred_hatch( + document, + handle, + dimasz, + defer_arrow_hatches, + ); + (arrow.clone(), arrow) + } else if dimsah { ( arrow_from_block_with_deferred_hatch( document, @@ -2894,6 +2978,11 @@ fn tessellate_dimension_inner( None } }; + let text_position = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); + let text_is_outside = dimension_text_is_outside(dim, style); + let horizontal_text = style.is_some_and(|style| { + (text_is_outside && style.dimtoh) || (!text_is_outside && style.dimtih) + }); let mut geom = dimension_geometry( dim, @@ -2912,6 +3001,8 @@ fn tessellate_dimension_inner( text_width, dimatfit: style.map(|s| s.dimatfit).unwrap_or(3), dimtofl: style.map(|s| s.dimtofl).unwrap_or(false), + text_position, + horizontal_text, text_break, }, SuppressFlags { @@ -2922,6 +3013,17 @@ fn tessellate_dimension_inner( }, ); + if !dimse1 { + if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( + document, + handle, + dimexo as f64, + ) { + let points: Vec = points.into_iter().map(vec3_local).collect(); + add_polyline(&mut geom.ext_lines, &points); + } + } + // DIMTMOVE = 1: when the saved text_middle_point sits far from the // dim-line anchor, draw a short leader connecting them. (=0 anchors text // to the dim line — no leader; =2 frees text without a leader.) @@ -3408,7 +3510,7 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { let off2 = def.dot(perp) - second.dot(perp); (first + perp * off1 + second + perp * off2) * 0.5 } - Dimension::Radius(d) => lv(d.definition_point), + Dimension::Radius(_) => return None, Dimension::Diameter(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5, _ => return None, }; @@ -3479,6 +3581,8 @@ struct DimLineParams { text_width: f32, dimatfit: i16, dimtofl: bool, + text_position: Vec3, + horizontal_text: bool, /// Text box (local centre, half-width, half-height) used to break the /// dimension line where the text sits on it, so a DIMTFILL background reads /// over the line. None when the text doesn't overlap the line. @@ -3533,7 +3637,7 @@ fn dimension_geometry( Dimension::Radius(d) => { let center = lv(d.angle_vertex); let point = lv(d.definition_point); - let text = dimension_text_position(dim); + let text = params.text_position; // A jogged radius dim (marked via XData) replaces the straight radial // leader with a foreshortened zig-zag at ~45° near its midpoint. let jogged = dim @@ -3551,29 +3655,50 @@ fn dimension_geometry( let mid = center + u * (dist * 0.5); let a = mid - u * half + perp * half; let b = mid + u * half - perp * half; - add_segment(&mut g.dim_lines, center, a); - add_segment(&mut g.dim_lines, a, b); - add_segment(&mut g.dim_lines, b, point); - } else { + if !suppress.dim1 { + add_segment(&mut g.dim_lines, center, a); + add_segment(&mut g.dim_lines, a, b); + add_segment(&mut g.dim_lines, b, point); + } + } else if !suppress.dim1 { add_segment(&mut g.dim_lines, center, point); } - // Honour leader_length: extend from the arrow tip past it - // toward the text by that distance along (text - point). - let leader_dir = normalized_or(text - point, Vec3::X); - let leader = if d.leader_length.abs() > 1e-9 { - point + leader_dir * (d.leader_length as f32) - } else { - text - }; - add_segment(&mut g.dim_lines, point, leader); + let radius = (point - center).length(); + let text_is_outside = text.distance(center) > radius + 1e-5; + if text_is_outside && !suppress.dim1 { + let radial = normalized_or(point - center, Vec3::X); + let angle_from_horizontal = radial.y.abs().atan2(radial.x.abs()); + if params.horizontal_text + && angle_from_horizontal > 15.0_f32.to_radians() + && radial.y.abs() > 1e-6 + { + let travel = (text.y - point.y) / radial.y; + if travel > 0.0 { + let elbow = point + radial * travel; + let landing_gap = params.text_width * 0.5 + params.arrow_len; + let landing = Vec3::new( + text.x - radial.x.signum() * landing_gap, + text.y, + text.z, + ); + add_segment(&mut g.dim_lines, point, elbow); + add_segment(&mut g.dim_lines, elbow, landing); + } else { + add_segment(&mut g.dim_lines, point, text); + } + } else { + add_segment(&mut g.dim_lines, point, text); + } + } append_arrow( &mut g, point, normalized_or(center - point, Vec3::X), arrow1, ); - let radius = (point - center).length(); - append_center_mark(&mut g, center, params.dimcen, radius); + if text_is_outside { + append_center_mark(&mut g, center, params.dimcen, radius); + } } Dimension::Diameter(d) => { // angle_vertex is the circle centre and definition_point a point on @@ -4288,6 +4413,34 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool let Some(style) = style else { return false; }; + if let Dimension::Radius(radius) = dim { + let dx = radius.definition_point.x - radius.angle_vertex.x; + let dy = radius.definition_point.y - radius.angle_vertex.y; + let available = dx.hypot(dy); + if dim.base().text_user_positioned { + let text = dim.base().text_middle_point; + return (text.x - radius.angle_vertex.x) + .hypot(text.y - radius.angle_vertex.y) + > available + 1e-9; + } + if style.dimtix { + return false; + } + let scale = if style.dimscale > 1e-9 { style.dimscale } else { 1.0 }; + let height = style.dimtxt * scale; + let gap = style.dimgap.abs() * scale; + let text_width = dimension_text_value(dim, Some(style)) + .map(|value| value.chars().count() as f64 * height * 0.6 + gap * 2.0) + .unwrap_or(0.0); + let arrow = style.dimasz * scale; + let insufficient = text_width + arrow > available; + return insufficient + && match style.dimatfit { + 0 | 2 => true, + 1 | 3 => text_width > available, + _ => text_width > available, + }; + } let (first, second, axis) = match dim { Dimension::Linear(d) => ( d.first_point, @@ -4338,6 +4491,8 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let dy = d.second_point.y - d.first_point.y; dy.atan2(dx) } + Dimension::Radius(d) => (d.definition_point.y - d.angle_vertex.y) + .atan2(d.definition_point.x - d.angle_vertex.x), _ => 0.0, }; // Clamp to (-π/2, π/2] so text never appears upside-down. @@ -5119,6 +5274,32 @@ fn dimension_text_pos_f64( dimtad, ) } + Dimension::Radius(d) => { + let dx = d.definition_point.x - d.angle_vertex.x; + let dy = d.definition_point.y - d.angle_vertex.y; + let radius = dx.hypot(dy).max(1e-12); + let ux = dx / radius; + let uy = dy / radius; + let outside = dimension_text_is_outside(dim, style); + let (mut x, mut y) = if outside { + let distance = arrow + text_w * 0.5 + dimgap; + ( + d.definition_point.x + ux * distance, + d.definition_point.y + uy * distance, + ) + } else { + ( + d.angle_vertex.x + ux * radius * 0.5, + d.angle_vertex.y + uy * radius * 0.5, + ) + }; + if dimtad != 0 { + let sign = if dimtad == 4 { -1.0 } else { 1.0 }; + x += -uy * perp_off * sign; + y += ux * perp_off * sign; + } + Vector3::new(x, y, d.definition_point.z) + } _ => { // Non-linear (radius / diameter / angular / ordinate): lift the // natural mid point straight up by the style offset. A user-dragged @@ -5127,11 +5308,6 @@ fn dimension_text_pos_f64( // here — that would ignore the style placement for auto-placed dims // and make a re-style a no-op. (#181) let mid = match dim { - Dimension::Radius(d) => Vector3::new( - (d.angle_vertex.x + d.definition_point.x) * 0.5, - (d.angle_vertex.y + d.definition_point.y) * 0.5, - (d.angle_vertex.z + d.definition_point.z) * 0.5, - ), Dimension::Diameter(d) => Vector3::new( (d.angle_vertex.x + d.definition_point.x) * 0.5, (d.angle_vertex.y + d.definition_point.y) * 0.5, From f7d8522520c3ab87120ba97b63a079f30a831b7a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:14:46 +0300 Subject: [PATCH 53/98] Restore radius extension line properties --- src/entities/dimension.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 3639654c..68873b44 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -2364,9 +2364,15 @@ pub fn style_sections( "dim_arrowhead_1", "dim_arrow_size", "dim_line_lineweight", + "dim_ext_line_lineweight", "dim_line_1", "dim_line_color", "dim_linetype", + "dim_ext_linetype_1", + "dim_ext_line_1", + "dim_ext_line_color", + "dim_ext_line_ext", + "dim_ext_line_offset", ]; lines .props From df8e42af8e8c839ef741b89548e4730d38ad3969 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 10:37:56 +0300 Subject: [PATCH 54/98] Correct plot style mode handling --- src/app/properties.rs | 40 ++++++++++++++++++--------------------- src/app/update/command.rs | 10 +++++++++- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index e4e639c2..417f8d43 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -203,11 +203,15 @@ impl OpenCADStudio { }) .unwrap_or_else(|| "ByLayer".to_string()) }; - let plot_style = match header.current_plotstyle_type { - 1 => "ByBlock", - 2 => "ByColor", - 3 => "ByObject", - _ => "ByLayer", + let plot_style = if header.plotstyle_mode { + "ByColor" + } else { + match header.current_plotstyle_type { + 1 => "ByBlock", + 2 => "Normal", + 3 => "ByObject", + _ => "ByLayer", + } }; let layout_plot_table = doc.objects.values().find_map(|object| { let acadrust::objects::ObjectType::Layout(layout) = object else { @@ -316,7 +320,7 @@ impl OpenCADStudio { Property { label: t!("Plot style").into_owned(), field: "plot_style", - value: if header.plotstyle_mode { + value: if !header.plotstyle_mode { PropValue::Choice { selected: plot_style.to_string(), options: vec![ @@ -327,7 +331,7 @@ impl OpenCADStudio { } } else { PropValue::ReadOnlyWithTooltip { - value: plot_style.to_string(), + value: "ByColor".to_string(), tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), } }, @@ -344,9 +348,9 @@ impl OpenCADStudio { read_only( t!("Plot table type").as_ref(), if header.plotstyle_mode { - "Named plot styles".to_string() - } else { "Color-dependent plot styles".to_string() + } else { + "Named plot styles".to_string() }, ), ], @@ -746,11 +750,10 @@ impl OpenCADStudio { } } - // In named plot-style mode (PSTYLEMODE=1) the Plot style row - // picks a named style from the drawing's plot style table. In - // the color-dependent mode it stays read-only (the object's - // color drives the plot style), so it is left untouched. - if self.tabs[i].scene.document.header.plotstyle_mode { + // Named plot-style mode exposes the drawing's styles. In + // color-dependent mode the object's color determines the + // style, so the row is locked to ByColor. + if !self.tabs[i].scene.document.header.plotstyle_mode { let doc = &self.tabs[i].scene.document; let dict_h = doc.header.acad_plotstylename_dict_handle; let common = entity.common(); @@ -797,15 +800,8 @@ impl OpenCADStudio { if let Some(row) = section.props.iter_mut().find(|p| p.field == "plot_style") { - let common = entity.common(); - let plot_style_str = match common.plotstyle_flags { - 0 => "ByLayer", - 1 => "ByBlock", - 2 => "Normal", - _ => "ByColor", - }; row.value = crate::scene::model::object::PropValue::ReadOnlyWithTooltip { - value: plot_style_str.to_string(), + value: "ByColor".to_string(), tooltip: t!("Plot style is locked to color in Color-Dependent (CTB) mode").into_owned(), }; } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index ab25667d..05ce1d1b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2169,6 +2169,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } else if field == "plot_style" { + if self.tabs[i].scene.document.header.plotstyle_mode { + self.refresh_properties(); + return Task::none(); + } // Named plot-style pick: ByLayer / ByBlock clear the // handle; a named style resolves through the drawing's // ACAD_PLOTSTYLENAME dictionary to its placeholder handle. @@ -2349,11 +2353,15 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.refresh_properties(); } "plot_style" => { + if self.tabs[i].scene.document.header.plotstyle_mode { + self.refresh_properties(); + return Task::none(); + } match value.as_str() { "ByBlock" => { self.tabs[i].scene.document.header.current_plotstyle_type = 1 } - "Normal" | "ByColor" => { + "Normal" => { self.tabs[i].scene.document.header.current_plotstyle_type = 2 } _ => self.tabs[i].scene.document.header.current_plotstyle_type = 0, From 656501e91d499fb9cf0753f6704328c3f4b7075b Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 14:28:00 +0300 Subject: [PATCH 55/98] Remove extra radius primary unit properties --- src/entities/dimension.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 68873b44..eaa45ee4 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -2391,6 +2391,17 @@ pub fn style_sections( center_type != "None", )); } + if let Some(primary_units) = sections + .iter_mut() + .find(|section| section.title == t!("Primary Units").as_ref()) + { + primary_units.props.retain(|property| { + !matches!( + property.field, + "dim_sub_units_suffix" | "dim_sub_units_scale" + ) + }); + } } sections From 488081f1c722f31c3a2db0480c0839efa758a736 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:39:38 +0300 Subject: [PATCH 56/98] Use corrected diameter dimension model --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 808da012..7629df01 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b2ae66#5b2ae66d0bd7b0da2d13392d3c3332f8a39caa1f" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=7e9a63b#7e9a63b84763fab82cbeb3cbe5721ed2a1c8efc3" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index dc49bfb1..a29e7179 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7e9a63b", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "7e9a63b" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } From 0ba33a55ff287b46be4880d473d263341b7294bb Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:39:44 +0300 Subject: [PATCH 57/98] Add associative diameter dimension workflow --- src/command.rs | 2 +- src/modules/annotate/diameter_dim.rs | 155 ++++++++---- src/scene/dimension_assoc.rs | 353 ++++++++++++++++++++++++++- 3 files changed, 463 insertions(+), 47 deletions(-) diff --git a/src/command.rs b/src/command.rs index 797b27b4..a79db230 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1212,7 +1212,7 @@ pub enum CmdResult { CommitEntitiesAndExit(Vec), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), - /// Commit a linear/aligned dimension using the drawing's association mode. + /// Commit a dimension using the drawing's association mode. /// Object-selection workflows may retain their source. CommitDimension { entity: EntityType, diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 1fe7ccfa..3bbc1c23 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -1,7 +1,7 @@ // DIMDIAMETER command — diameter dimension for circles and arcs. use acadrust::entities::{Dimension, DimensionDiameter}; -use acadrust::types::Vector3; +use acadrust::types::{Handle, Vector3}; use acadrust::EntityType; use glam::{DVec3, Vec3}; @@ -22,14 +22,12 @@ pub fn tool() -> ToolDef { } enum Step { - CenterPoint, - ArcPoint(DVec3), - TextPoint { center: DVec3, arc_pt: DVec3 }, + SelectObject, + DimLine(crate::scene::dimension_assoc::RadialSourceGeometry), } pub struct DiameterDimensionCommand { step: Step, - plane: WorkingPlane, /// Optional text that replaces the measured value (None = measurement). text_override: Option, /// True while the next typed line is captured as the text override. @@ -38,24 +36,28 @@ pub struct DiameterDimensionCommand { text_angle: Option, /// True while the next typed value is captured as the text angle. awaiting_angle: bool, + picked_entity: Option, + source_handle: Option, + mtext_override: bool, } impl DiameterDimensionCommand { pub fn new() -> Self { Self { - step: Step::CenterPoint, - plane: WorkingPlane::default(), + step: Step::SelectObject, text_override: None, awaiting_text: false, text_angle: None, awaiting_angle: false, + picked_entity: None, + source_handle: None, + mtext_override: false, } } } impl CadCommand for DiameterDimensionCommand { - fn set_working_plane(&mut self, plane: WorkingPlane) { - self.plane = plane; + fn set_working_plane(&mut self, _plane: WorkingPlane) { } fn name(&self) -> &'static str { @@ -64,48 +66,58 @@ impl CadCommand for DiameterDimensionCommand { fn prompt(&self) -> String { if self.awaiting_text { - return t!("DIMDIAMETER Enter dimension text (blank = measured value):").into_owned(); + return if self.mtext_override { + t!("DIMDIAMETER Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMDIAMETER Enter dimension text (blank = measured value):").into_owned() + }; } if self.awaiting_angle { return t!("DIMDIAMETER Specify text angle (degrees):").into_owned(); } match self.step { - Step::CenterPoint => t!("DIMDIAMETER Specify center point:").into_owned(), - Step::ArcPoint(_) => t!("DIMDIAMETER Specify point on circle:").into_owned(), - Step::TextPoint { .. } => { - t!("DIMDIAMETER Specify dimension line location [Text/Angle]:").into_owned() + Step::SelectObject => { + t!("DIMDIAMETER Select arc, circle, or polyline arc:").into_owned() + } + Step::DimLine(_) => { + t!("DIMDIAMETER Specify dimension line location [Mtext/Text/Angle]:") + .into_owned() } } } fn on_point(&mut self, pt: DVec3) -> CmdResult { match self.step { - Step::CenterPoint => { - self.step = Step::ArcPoint(pt); - CmdResult::NeedPoint - } - Step::ArcPoint(center) => { - self.step = Step::TextPoint { center, arc_pt: pt }; - CmdResult::NeedPoint - } - Step::TextPoint { center, arc_pt } => { - let center = self.plane.to_local(center); - let arc_pt = self.plane.to_local(arc_pt); - let pt = self.plane.to_local(pt); - let mut dim = DimensionDiameter::new(v3(center), v3(arc_pt)); - dim.base.definition_point = v3(arc_pt); + Step::SelectObject => CmdResult::NeedPoint, + Step::DimLine(source) => { + let source_plane = WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ); + let chord = source_plane.to_local(dvec(source.chord_at(pt.to_array()))); + let far_chord = source_plane.to_local(dvec(source.opposite_chord_at(pt.to_array()))); + let pt = source_plane.to_local(pt); + let mut dim = DimensionDiameter::new(v3(chord), v3(far_chord)); dim.base.text_middle_point = v3(pt); dim.base.insertion_point = v3(pt); - dim.leader_length = arc_pt.distance(pt); + dim.base.text_user_positioned = true; + dim.leader_length = chord.distance(pt); dim.base.actual_measurement = dim.measurement(); - dim.base.user_text = self.text_override.clone(); + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); // An explicit text angle overrides the default rotation. if let Some(a) = self.text_angle { dim.base.text_rotation = a; } - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Diameter(dim), - ))) + CmdResult::CommitDimension { + entity: source_plane + .place_entity(EntityType::Dimension(Dimension::Diameter(dim))), + source: self.source_handle, + } } } } @@ -160,8 +172,17 @@ impl CadCommand for DiameterDimensionCommand { self.awaiting_angle = false; return Some(CmdResult::NeedPoint); } + if !matches!(self.step, Step::DimLine(_)) { + return None; + } match text.trim().to_uppercase().as_str() { - "T" | "TEXT" | "M" | "MTEXT" => { + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; self.awaiting_text = true; Some(CmdResult::NeedPoint) } @@ -173,14 +194,47 @@ impl CadCommand for DiameterDimensionCommand { } } + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::SelectObject) + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(source) = crate::scene::dimension_assoc::radial_source_at( + &entity, + Vector3::new(point.x, point.y, point.z), + ) else { + return CmdResult::NeedPoint; + }; + if !source.radius.is_finite() || source.radius <= 1e-12 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.step = Step::DimLine(source); + CmdResult::NeedPoint + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let pt = pt.as_vec3(); match self.step { - Step::CenterPoint => None, - Step::ArcPoint(center) => Some(preview_line(center.as_vec3(), pt)), - Step::TextPoint { center, arc_pt } => { - let far = center + (center - arc_pt); // opposite point on circle - Some(preview_line(far.as_vec3(), pt)) + Step::SelectObject => None, + Step::DimLine(source) => { + let chord = dvec(source.chord_at(pt.to_array())).as_vec3(); + let far_chord = dvec(source.opposite_chord_at(pt.to_array())).as_vec3(); + Some(preview_line(far_chord, chord, pt.as_vec3())) } } } @@ -190,7 +244,12 @@ fn v3(p: DVec3) -> Vector3 { Vector3::new(p.x, p.y, p.z) } -fn preview_line(a: Vec3, b: Vec3) -> WireModel { +fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel { + let separator = [f32::NAN; 3]; WireModel { point_marker: None, taper_widths: Vec::new(), @@ -204,11 +263,17 @@ fn preview_line(a: Vec3, b: Vec3) -> WireModel { render_instance: None, pick_tris: Vec::new(), pick_tris_low: Vec::new(), - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), name: "dimdia_preview".into(), - points: vec![[a.x, a.y, a.z], [b.x, b.y, b.z]], + points: vec![ + far_chord.to_array(), + chord.to_array(), + separator, + chord.to_array(), + text.to_array(), + ], points_low: Vec::new(), color: WireModel::CYAN, selected: false, diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 2814695e..5989179a 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -8,10 +8,198 @@ use acadrust::EntityType; use cadkernel::geom2d::{ closest_point, Circle as KernelCircle, Curve as KernelCurve, }; +use cadkernel::space::Plane; use std::f64::consts::TAU; use super::{ChangeKind, Scene}; +#[derive(Clone, Copy, Debug)] +pub(crate) struct RadialSourceGeometry { + pub plane: Plane, + pub center: [f64; 2], + pub radius: f64, + pub start_angle: f64, + pub sweep: f64, + pub limited: bool, + pub marker: i32, +} + +impl RadialSourceGeometry { + pub(crate) fn center_world(self) -> Vector3 { + vector3(self.plane.point_at(self.center)) + } + + pub(crate) fn point_at_angle(self, angle: f64) -> Vector3 { + vector3(self.plane.point_at([ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ])) + } + + pub(crate) fn angle_at(self, point: DPoint) -> f64 { + let projected = self.plane.project(point).unwrap_or(self.center); + (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]) + } + + pub(crate) fn chord_at(self, point: DPoint) -> Vector3 { + self.point_at_angle(self.angle_at(point)) + } + + pub(crate) fn opposite_chord_at(self, point: DPoint) -> Vector3 { + self.point_at_angle(self.angle_at(point) + std::f64::consts::PI) + } + + fn contains_angle(self, angle: f64) -> bool { + if !self.limited { + return true; + } + signed_progress(self.start_angle, angle, self.sweep) + .is_some_and(|progress| progress <= self.sweep.abs() + 1e-10) + } + + fn distance_squared_to(self, point: DPoint) -> f64 { + let Some(projected) = self.plane.project(point) else { + return f64::INFINITY; + }; + let angle = (projected[1] - self.center[1]).atan2(projected[0] - self.center[0]); + let radial = ((projected[0] - self.center[0]).hypot(projected[1] - self.center[1]) + - self.radius) + .abs(); + let candidate = if self.contains_angle(angle) { + [ + self.center[0] + self.radius * angle.cos(), + self.center[1] + self.radius * angle.sin(), + ] + } else { + let start = [ + self.center[0] + self.radius * self.start_angle.cos(), + self.center[1] + self.radius * self.start_angle.sin(), + ]; + let end_angle = self.start_angle + self.sweep; + let end = [ + self.center[0] + self.radius * end_angle.cos(), + self.center[1] + self.radius * end_angle.sin(), + ]; + if squared_2d(start, projected) <= squared_2d(end, projected) { + start + } else { + end + } + }; + let planar = if self.contains_angle(angle) { + radial * radial + } else { + squared_2d(candidate, projected) + }; + let world = self.plane.point_at(projected); + let dx = world[0] - point[0]; + let dy = world[1] - point[1]; + let dz = world[2] - point[2]; + planar + dx * dx + dy * dy + dz * dz + } +} + +type DPoint = [f64; 3]; + +fn vector3(point: DPoint) -> Vector3 { + Vector3::new(point[0], point[1], point[2]) +} + +fn dpoint(point: Vector3) -> DPoint { + [point.x, point.y, point.z] +} + +fn squared_2d(first: [f64; 2], second: [f64; 2]) -> f64 { + let dx = first[0] - second[0]; + let dy = first[1] - second[1]; + dx * dx + dy * dy +} + +fn signed_progress(start: f64, angle: f64, sweep: f64) -> Option { + if !start.is_finite() || !angle.is_finite() || !sweep.is_finite() || sweep.abs() <= 1e-12 { + return None; + } + Some(if sweep > 0.0 { + (angle - start).rem_euclid(TAU) + } else { + (start - angle).rem_euclid(TAU) + }) +} + +fn radial_candidates(entity: &EntityType) -> Vec { + let Some(planar) = crate::entities::curve::entity_curve(entity) else { + return Vec::new(); + }; + match planar.curve { + KernelCurve::Circle(circle) => vec![RadialSourceGeometry { + plane: planar.plane, + center: circle.centre, + radius: circle.radius, + start_angle: 0.0, + sweep: TAU, + limited: false, + marker: 0, + }], + KernelCurve::Arc(arc) => vec![RadialSourceGeometry { + plane: planar.plane, + center: arc.centre, + radius: arc.radius, + start_angle: arc.start_angle, + sweep: arc.sweep(), + limited: true, + marker: 0, + }], + KernelCurve::Polyline(polyline) => (0..polyline.vertices.len()) + .filter_map(|index| { + let arc = polyline.segment_arc(index)?; + Some(RadialSourceGeometry { + plane: planar.plane, + center: arc.center, + radius: arc.radius, + start_angle: arc.start_angle, + sweep: arc.sweep, + limited: true, + marker: index as i32, + }) + }) + .collect(), + _ => Vec::new(), + } +} + +pub(crate) fn radial_source_at( + entity: &EntityType, + point: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + first + .distance_squared_to(dpoint(point)) + .total_cmp(&second.distance_squared_to(dpoint(point))) + }) +} + +fn radial_source_for_marker(entity: &EntityType, marker: i32) -> Option { + radial_candidates(entity) + .into_iter() + .find(|candidate| candidate.marker == marker) +} + +fn radial_source_matching( + entity: &EntityType, + center: Vector3, + radius: f64, + chord: Vector3, +) -> Option { + radial_candidates(entity).into_iter().min_by(|first, second| { + let score = |candidate: &RadialSourceGeometry| { + point_distance_squared(candidate.center_world(), center) + + (candidate.radius - radius).powi(2) + + candidate.distance_squared_to(dpoint(chord)) * 1e-6 + }; + score(first).total_cmp(&score(second)) + }) +} + fn point_distance_squared(first: Vector3, second: Vector3) -> f64 { let dx = first.x - second.x; let dy = first.y - second.y; @@ -172,6 +360,71 @@ pub(crate) fn dimension_is_associative( }) } +pub(crate) fn radial_extension_points( + document: &acadrust::CadDocument, + dimension: Handle, + gap: f64, + extension: f64, +) -> Option> { + let EntityType::Dimension(Dimension::Diameter(diameter)) = document.get_entity(dimension)? + else { + return None; + }; + let association = document.objects.values().find_map(|object| { + let ObjectType::Associative(object) = object else { + return None; + }; + let AssociativeData::DimensionAssociation(association) = &object.data else { + return None; + }; + (association.dimension == dimension).then_some(association) + })?; + let reference = association.references[0].first()?; + let source = document.get_entity(*reference.xrefs.first()?)?; + let radial = radial_source_for_marker(source, reference.main_gs_marker)?; + if !radial.limited || radial.radius <= 1e-12 { + return None; + } + let target = radial.angle_at(dpoint(diameter.angle_vertex)); + if radial.contains_angle(target) { + return None; + } + + let end = radial.start_angle + radial.sweep; + let direction = radial.sweep.signum(); + let from_start = if direction > 0.0 { + (radial.start_angle - target).rem_euclid(TAU) + } else { + (target - radial.start_angle).rem_euclid(TAU) + }; + let from_end = if direction > 0.0 { + (target - end).rem_euclid(TAU) + } else { + (end - target).rem_euclid(TAU) + }; + let (origin, extension_direction, span) = if from_start <= from_end { + (radial.start_angle, -direction, from_start) + } else { + (end, direction, from_end) + }; + let gap_angle = gap.max(0.0) / radial.radius; + if span <= gap_angle + 1e-10 { + return None; + } + let extension_angle = extension.max(0.0) / radial.radius; + let visible_span = span - gap_angle + extension_angle; + let first_angle = origin + extension_direction * gap_angle; + let segments = (visible_span / (5.0_f64.to_radians())).ceil().max(1.0) as usize; + Some( + (0..=segments) + .map(|index| { + let fraction = index as f64 / segments as f64; + radial.point_at_angle(first_angle + extension_direction * visible_span * fraction) + }) + .collect(), + ) +} + impl Scene { pub(crate) fn attach_dimension_association( &mut self, @@ -182,6 +435,42 @@ impl Scene { else { return; }; + if let Dimension::Diameter(diameter) = entity { + let Some(source) = sources.into_iter().flatten().next() else { + return; + }; + let Some(source_entity) = self.document.get_entity(source) else { + return; + }; + let center = diameter.center(); + let radius = diameter.measurement() * 0.5; + let Some(radial) = radial_source_matching( + source_entity, + center, + radius, + diameter.angle_vertex, + ) else { + return; + }; + let angle = radial.angle_at(dpoint(diameter.angle_vertex)); + let reference = AssocDimensionReference { + class_name: "AcDbOsnapPointRef".to_string(), + osnap_type: 10, + xrefs: vec![source], + main_subent_type: 1, + main_gs_marker: radial.marker, + osnap_distance: angle, + osnap_point: diameter.angle_vertex, + ..AssocDimensionReference::default() + }; + self.store_dimension_association( + dimension, + [vec![reference], Vec::new(), Vec::new(), Vec::new()], + 1, + [Some(source), None], + ); + return; + } let Some(source_data) = dimension_points(entity) else { return; }; @@ -235,6 +524,16 @@ impl Scene { } } + self.store_dimension_association(dimension, references, associativity, sources); + } + + fn store_dimension_association( + &mut self, + dimension: Handle, + references: [Vec; 4], + associativity: i32, + sources: [Option; 2], + ) { let association_handle = self.document.allocate_handle(); let mut object = AssociativeObject::new("DIMASSOC", "AcDbDimAssoc"); object.handle = association_handle; @@ -270,6 +569,33 @@ impl Scene { else { return [None, None]; }; + if let Dimension::Diameter(diameter) = entity { + let measurement = diameter.measurement(); + let radius = measurement * 0.5; + let center = diameter.center(); + let tolerance = measurement.abs().max(1.0) * 1e-9; + let source = self + .document + .entities() + .filter(|candidate| candidate.common().handle != dimension) + .filter_map(|candidate| { + let radial = radial_source_matching( + candidate, + center, + radius, + diameter.angle_vertex, + )?; + let center_error = point_distance_squared(radial.center_world(), center); + let radius_error = (radial.radius - radius).powi(2); + (center_error + radius_error <= tolerance * tolerance).then_some(( + center_error + radius_error, + candidate.common().handle, + )) + }) + .min_by(|first, second| first.0.total_cmp(&second.0)) + .map(|(_, handle)| handle); + return [source, None]; + } let Some(points) = dimension_points(entity) else { return [None, None]; }; @@ -318,13 +644,19 @@ impl Scene { let mut refreshed = Vec::new(); for association in associations { + let radial_source = association.references[0].first().and_then(|reference| { + let source = *reference.xrefs.first()?; + let entity = self.document.get_entity(source)?; + let radial = radial_source_for_marker(entity, reference.main_gs_marker)?; + Some((radial, reference.osnap_distance)) + }); let first = association.references[0] .first() .and_then(|reference| resolve_reference(self, reference)); let second = association.references[1] .first() .and_then(|reference| resolve_reference(self, reference)); - if first.is_none() && second.is_none() { + if first.is_none() && second.is_none() && radial_source.is_none() { continue; } let Some(EntityType::Dimension(dimension)) = @@ -353,6 +685,25 @@ impl Scene { aligned.base.actual_measurement = aligned.measurement(); aligned.base.definition_point = aligned.definition_point; } + Dimension::Diameter(diameter) => { + let Some((radial, angle)) = radial_source else { + continue; + }; + let old_chord = diameter.angle_vertex; + let new_chord = radial.point_at_angle(angle); + let new_far_chord = radial.point_at_angle(angle + std::f64::consts::PI); + let delta = Vector3::new( + new_chord.x - old_chord.x, + new_chord.y - old_chord.y, + new_chord.z - old_chord.z, + ); + diameter.angle_vertex = new_chord; + diameter.definition_point = new_far_chord; + diameter.base.definition_point = new_far_chord; + diameter.base.text_middle_point = diameter.base.text_middle_point + delta; + diameter.base.insertion_point = diameter.base.insertion_point + delta; + diameter.base.actual_measurement = diameter.measurement(); + } _ => continue, } refreshed.push((association.dimension, ChangeKind::Modified)); From 75f042607ec027d60a5703b2a81fa04ca8b442fa Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:39:48 +0300 Subject: [PATCH 58/98] Align diameter properties and graphics --- src/entities/dim_override.rs | 25 +++ src/entities/dimension.rs | 338 ++++++++++++++++++++++++++--- src/modules/draw/modify/explode.rs | 179 +++++++++++++-- 3 files changed, 492 insertions(+), 50 deletions(-) diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 13ce51b8..868bbab6 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -352,6 +352,7 @@ fn inherited_real(doc: &CadDocument, handle: Handle, code: i16) -> f64 { }; match code { DIMGAP => style.dimgap, + DIMCEN => style.dimcen, DIMTP => style.dimtp, DIMTM => style.dimtm, _ => 0.0, @@ -390,6 +391,30 @@ pub fn set_property( value: &str, ) -> bool { let trimmed = value.trim(); + if field == "dim_center_type" { + let current = inherited_real(doc, handle, DIMCEN); + let size = current.abs().max(0.01); + let value = match trimmed.to_ascii_lowercase().as_str() { + "none" => 0.0, + "mark" => size, + "lines" => -size, + _ => return false, + }; + set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); + return true; + } + if field == "dim_center_size" { + let Ok(size) = trimmed.parse::() else { + return false; + }; + if !size.is_finite() || size < 0.0 { + return false; + } + let current = inherited_real(doc, handle, DIMCEN); + let value = if current < 0.0 { -size } else { size }; + set(doc, handle, DIMCEN, Some(XDataValue::Real(value))); + return true; + } let handle_field = match field { "dim_arrowhead_1" => Some(DIMBLK1), "dim_arrowhead_2" => Some(DIMBLK2), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index ae303266..c633e27b 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -86,6 +86,23 @@ fn base_props(base: &DimensionBase) -> Vec Vec { + if let Dimension::Diameter(diameter) = dim { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![ + Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: PropValue::PlainText(diameter.base.style_name.clone()), + }, + edit( + t!("Leader Length").as_ref(), + "leader_length", + diameter.leader_length, + ), + ], + }]; + } let compact_linear = match dim { Dimension::Linear(d) => Some(( &d.base, @@ -1268,11 +1285,29 @@ impl Grippable for Dimension { 1 => apply_to_v3(&mut d.definition_point, &apply), _ => {} }, - Dimension::Diameter(d) => match grip_id { - 0 => apply_to_v3(&mut d.angle_vertex, &apply), - 1 => apply_to_v3(&mut d.definition_point, &apply), - _ => {} - }, + Dimension::Diameter(d) => { + let center = d.center(); + let radius = d.measurement() * 0.5; + let mut target = match grip_id { + 0 => d.angle_vertex, + 1 => d.definition_point, + _ => center, + }; + if grip_id <= 1 { + apply_to_v3(&mut target, &apply); + let offset = target - center; + if offset.length_squared() > 1e-24 && radius > 1e-12 { + let radial = offset.normalize() * radius; + if grip_id == 0 { + d.angle_vertex = center + radial; + d.definition_point = center - radial; + } else { + d.definition_point = center + radial; + d.angle_vertex = center - radial; + } + } + } + } Dimension::Angular2Ln(d) => match grip_id { 0 => apply_to_v3(&mut d.angle_vertex, &apply), 1 => apply_to_v3(&mut d.first_point, &apply), @@ -1636,8 +1671,15 @@ pub fn style_sections( let linetype = |code, inherited| { linetype_name(document, ov::handle(data, code).unwrap_or(inherited)) }; - let arrow_1 = arrow_name(ov::DIMBLK1, s.dimblk1, &s.dimblk1_name); - let arrow_2 = arrow_name(ov::DIMBLK2, s.dimblk2, &s.dimblk2_name); + let diameter_arrow = |code: i16, handle: acadrust::types::Handle, name: &str| { + if matches!(dimension, Dimension::Diameter(_)) && !s.dimsah && handle.is_null() { + arrow_name(code, s.dimblk, &s.dimblk_name) + } else { + arrow_name(code, handle, name) + } + }; + let arrow_1 = diameter_arrow(ov::DIMBLK1, s.dimblk1, &s.dimblk1_name); + let arrow_2 = diameter_arrow(ov::DIMBLK2, s.dimblk2, &s.dimblk2_name); for current in [&arrow_1, &arrow_2] { if !arrow_options.contains(current) { arrow_options.push(current.clone()); @@ -1682,7 +1724,7 @@ pub fn style_sections( _ => choice_value("None", &["None", "Background", "Color"]), }; - vec![ + let mut sections = vec![ PropSection { title: t!("Lines & Arrows").into_owned(), props: vec![ @@ -2312,7 +2354,68 @@ pub fn style_sections( ), ], }, - ] + ]; + + if matches!(dimension, Dimension::Diameter(_)) { + let dimcen = real(ov::DIMCEN, s.dimcen); + let center_type = if dimcen > 1e-12 { + "Mark" + } else if dimcen < -1e-12 { + "Lines" + } else { + "None" + }; + if let Some(lines) = sections + .iter_mut() + .find(|section| section.title == t!("Lines & Arrows").as_ref()) + { + const DIAMETER_LINE_FIELDS: &[&str] = &[ + "dim_arrowhead_1", + "dim_arrowhead_2", + "dim_arrow_size", + "dim_line_lineweight", + "dim_ext_line_lineweight", + "dim_line_1", + "dim_line_2", + "dim_line_color", + "dim_linetype", + "dim_ext_linetype_1", + "dim_ext_line_1", + "dim_ext_line_color", + "dim_ext_line_ext", + "dim_ext_line_offset", + ]; + lines + .props + .retain(|property| DIAMETER_LINE_FIELDS.contains(&property.field)); + lines.props.insert(3, choice( + t!("Center mark").as_ref(), + "dim_center_type", + center_type, + &["None", "Mark", "Lines"], + true, + )); + lines.props.insert(4, number( + t!("Center size").as_ref(), + "dim_center_size", + dimcen.abs(), + center_type != "None", + )); + } + if let Some(primary_units) = sections + .iter_mut() + .find(|section| section.title == t!("Primary Units").as_ref()) + { + primary_units.props.retain(|property| { + !matches!( + property.field, + "dim_sub_units_suffix" | "dim_sub_units_scale" + ) + }); + } + } + + sections } fn property(label: &str, field: &'static str, value: PropValue) -> Property { @@ -2835,7 +2938,33 @@ fn tessellate_dimension_inner( }; (t.clone(), t) } else if let Some(s) = style { - if dimsah { + if matches!(dim, Dimension::Diameter(_)) { + let data = &dim.base().common.extended_data; + let first = crate::entities::dim_override::handle( + data, + crate::entities::dim_override::DIMBLK1, + ) + .unwrap_or(if dimsah { s.dimblk1 } else { s.dimblk }); + let second = crate::entities::dim_override::handle( + data, + crate::entities::dim_override::DIMBLK2, + ) + .unwrap_or(if dimsah { s.dimblk2 } else { s.dimblk }); + ( + arrow_from_block_with_deferred_hatch( + document, + first, + dimasz, + defer_arrow_hatches, + ), + arrow_from_block_with_deferred_hatch( + document, + second, + dimasz, + defer_arrow_hatches, + ), + ) + } else if dimsah { ( arrow_from_block_with_deferred_hatch( document, @@ -2894,6 +3023,7 @@ fn tessellate_dimension_inner( None } }; + let text_position = vec3_local(dimension_text_pos_f64(dim, style, dim_txt, dim_scale)); let mut geom = dimension_geometry( dim, @@ -2912,6 +3042,8 @@ fn tessellate_dimension_inner( text_width, dimatfit: style.map(|s| s.dimatfit).unwrap_or(3), dimtofl: style.map(|s| s.dimtofl).unwrap_or(false), + text_position, + text_movement: style.map(|s| s.dimtmove).unwrap_or(0), text_break, }, SuppressFlags { @@ -2922,6 +3054,18 @@ fn tessellate_dimension_inner( }, ); + if !dimse1 { + if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( + document, + handle, + dimexo as f64, + dimexe as f64, + ) { + let points: Vec = points.into_iter().map(vec3_local).collect(); + add_polyline(&mut geom.ext_lines, &points); + } + } + // DIMTMOVE = 1: when the saved text_middle_point sits far from the // dim-line anchor, draw a short leader connecting them. (=0 anchors text // to the dim line — no leader; =2 frees text without a leader.) @@ -3409,7 +3553,15 @@ fn dimtmove_leader_endpoints(dim: &Dimension) -> Option<(Vec3, Vec3)> { (first + perp * off1 + second + perp * off2) * 0.5 } Dimension::Radius(d) => lv(d.definition_point), - Dimension::Diameter(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5, + Dimension::Diameter(d) => { + let chord = lv(d.angle_vertex); + let far_chord = lv(d.definition_point); + if chord.distance_squared(lv(txt)) <= far_chord.distance_squared(lv(txt)) { + chord + } else { + far_chord + } + } _ => return None, }; Some((anchor, lv(txt))) @@ -3479,6 +3631,8 @@ struct DimLineParams { text_width: f32, dimatfit: i16, dimtofl: bool, + text_position: Vec3, + text_movement: i16, /// Text box (local centre, half-width, half-height) used to break the /// dimension line where the text sits on it, so a DIMTFILL background reads /// over the line. None when the text doesn't overlap the line. @@ -3576,26 +3730,21 @@ fn dimension_geometry( append_center_mark(&mut g, center, params.dimcen, radius); } Dimension::Diameter(d) => { - // angle_vertex is the circle centre and definition_point a point on - // the circle. The diameter line runs edge-to-edge THROUGH the centre - // (far edge → near edge), with arrows pointing inward at each edge. - let center = lv(d.angle_vertex); - let edge = lv(d.definition_point); - let far = center * 2.0 - edge; - add_segment(&mut g.dim_lines, far, edge); - append_arrow(&mut g, edge, normalized_or(far - edge, Vec3::X), arrow1); - append_arrow(&mut g, far, normalized_or(edge - far, Vec3::X), arrow2); - // Diameter leader: continue past the near edge toward the text. - if d.leader_length.abs() > 1e-9 { - let text = dimension_text_position(dim); - let leader_dir = normalized_or(text - edge, edge - far); - add_segment( - &mut g.dim_lines, - edge, - edge + leader_dir * (d.leader_length as f32), - ); - } - let radius = (edge - center).length(); + // Diametric data stores the two opposite chord points directly. + let chord = lv(d.angle_vertex); + let far_chord = lv(d.definition_point); + append_diameter_dimension( + &mut g, + chord, + far_chord, + arrow1, + arrow2, + d.leader_length as f32, + params, + suppress, + ); + let center = (chord + far_chord) * 0.5; + let radius = chord.distance(far_chord) * 0.5; append_center_mark(&mut g, center, params.dimcen, radius); } Dimension::Angular2Ln(d) => { @@ -3862,6 +4011,107 @@ fn append_linear_dimension( } } +fn append_diameter_dimension( + g: &mut DimGeom, + chord: Vec3, + far_chord: Vec3, + arrow1: &ArrowKind, + arrow2: &ArrowKind, + leader_length: f32, + params: DimLineParams, + suppress: SuppressFlags, +) { + let axis = normalized_or(far_chord - chord, Vec3::X); + let diameter = chord.distance(far_chord); + if diameter <= 1e-6 { + return; + } + + let arrows_outside = if params.ticks || params.arrow_len <= 1e-6 { + false + } else if diameter < 2.0 * params.arrow_len { + true + } else if diameter < params.text_width + 2.0 * params.arrow_len { + match params.dimatfit { + 0 | 1 => true, + 2 => false, + _ => params.text_width <= diameter, + } + } else { + false + }; + + let extension = if params.ticks { params.dimdle } else { 0.0 }; + let first = chord - axis * extension; + let second = far_chord + axis * extension; + let line_length = first.distance(second); + let mut left_end = line_length * 0.5; + let mut right_start = left_end; + if let Some((text_center, half_width, half_height)) = params.text_break { + let along = (text_center - first).dot(axis); + if along > 0.0 && along < line_length { + left_end = along; + right_start = along; + let perpendicular = (text_center - (first + axis * along)).length(); + if perpendicular < half_height + && along - half_width > 0.0 + && along + half_width < line_length + { + left_end = along - half_width; + right_start = along + half_width; + } + } + } + + let draw_inside_line = !arrows_outside || params.dimtofl; + if draw_inside_line && !suppress.dim1 && left_end > 1e-6 { + add_segment(&mut g.dim_lines, first, first + axis * left_end); + } + if draw_inside_line && !suppress.dim2 && line_length - right_start > 1e-6 { + add_segment( + &mut g.dim_lines, + first + axis * right_start, + second, + ); + } + if arrows_outside && !params.dimsoxd { + let stub = params.arrow_len * 2.0; + if !suppress.dim1 { + add_segment(&mut g.dim_lines, chord - axis * stub, chord); + } + if !suppress.dim2 { + add_segment(&mut g.dim_lines, far_chord, far_chord + axis * stub); + } + } + + if arrows_outside { + append_arrow(g, chord, -axis, arrow1); + append_arrow(g, far_chord, axis, arrow2); + } else { + append_arrow(g, chord, axis, arrow1); + append_arrow(g, far_chord, -axis, arrow2); + } + + let text_along = (params.text_position - chord).dot(axis); + if params.text_movement == 0 { + let (tip, suppressed) = if params.text_position.distance_squared(chord) + <= params.text_position.distance_squared(far_chord) + { + (chord, suppress.dim1) + } else { + (far_chord, suppress.dim2) + }; + if !suppressed && leader_length.abs() > 1e-6 { + let direction = normalized_or(params.text_position - tip, axis); + add_segment(&mut g.dim_lines, tip, tip + direction * leader_length.abs()); + } else if text_along < 0.0 && !suppress.dim1 { + add_segment(&mut g.dim_lines, chord, params.text_position); + } else if text_along > diameter && !suppress.dim2 { + add_segment(&mut g.dim_lines, far_chord, params.text_position); + } + } +} + /// Draw a center mark for radius/diameter dimensions. /// DIMCEN > 0 → small "+" of half-length |DIMCEN| at the centre. /// DIMCEN < 0 → small "+" *plus* four line segments extending from the @@ -4299,6 +4549,11 @@ fn dimension_text_is_outside(dim: &Dimension, style: Option<&DimStyle>) -> bool let length = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1e-12); (d.first_point, d.second_point, delta / length) } + Dimension::Diameter(d) => { + let delta = d.definition_point - d.angle_vertex; + let length = (delta.x * delta.x + delta.y * delta.y).sqrt().max(1e-12); + (d.angle_vertex, d.definition_point, delta / length) + } _ => return false, }; let first_axis = first.x * axis.x + first.y * axis.y; @@ -4338,6 +4593,8 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let dy = d.second_point.y - d.first_point.y; dy.atan2(dx) } + Dimension::Diameter(d) => (d.definition_point.y - d.angle_vertex.y) + .atan2(d.definition_point.x - d.angle_vertex.x), _ => 0.0, }; // Clamp to (-π/2, π/2] so text never appears upside-down. @@ -5119,6 +5376,25 @@ fn dimension_text_pos_f64( dimtad, ) } + Dimension::Diameter(d) => { + let dx = d.definition_point.x - d.angle_vertex.x; + let dy = d.definition_point.y - d.angle_vertex.y; + let len = (dx * dx + dy * dy).sqrt().max(1e-12); + text_on_dim_line( + d.angle_vertex, + d.definition_point, + d.angle_vertex, + dx / len, + dy / len, + dimjust, + perp_off, + text_w, + arrow, + dimtix, + dimatfit, + dimtad, + ) + } _ => { // Non-linear (radius / diameter / angular / ordinate): lift the // natural mid point straight up by the style offset. A user-dragged diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 302033f9..f4a3e483 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -612,10 +612,66 @@ struct DimMetrics { /// suppression), DIMCLRD/E/T (colours) and DIMLWD/E (lineweights). fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics { let name = dim.base().style_name.as_str(); - let style = doc.dim_styles.iter().find(|s| { + let mut effective_style = doc.dim_styles.iter().find(|s| { s.name.eq_ignore_ascii_case(name) || (name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard")) - }); + }).cloned(); + if let Some(style) = &mut effective_style { + use crate::entities::dim_override as ov; + let data = &dim.base().common.extended_data; + macro_rules! real { + ($field:ident, $code:ident) => { + if let Some(value) = ov::real(data, ov::$code) { + style.$field = value; + } + }; + } + macro_rules! int { + ($field:ident, $code:ident) => { + if let Some(value) = ov::int(data, ov::$code) { + style.$field = value; + } + }; + } + macro_rules! flag { + ($field:ident, $code:ident) => { + if let Some(value) = ov::int(data, ov::$code) { + style.$field = value != 0; + } + }; + } + macro_rules! handle { + ($field:ident, $code:ident) => { + if let Some(value) = ov::handle(data, ov::$code) { + style.$field = value; + } + }; + } + real!(dimscale, DIMSCALE); + real!(dimasz, DIMASZ); + real!(dimcen, DIMCEN); + real!(dimexo, DIMEXO); + real!(dimexe, DIMEXE); + real!(dimtsz, DIMTSZ); + real!(dimdle, DIMDLE); + real!(dimfxl, DIMFXL); + flag!(dimfxlon, DIMFXLON); + flag!(dimse1, DIMSE1); + flag!(dimse2, DIMSE2); + flag!(dimsd1, DIMSD1); + flag!(dimsd2, DIMSD2); + flag!(dimsoxd, DIMSOXD); + flag!(dimsah, DIMSAH); + int!(dimclrd, DIMCLRD); + int!(dimclre, DIMCLRE); + int!(dimclrt, DIMCLRT); + int!(dimlwd, DIMLWD); + int!(dimlwe, DIMLWE); + handle!(dimblk, DIMBLK); + handle!(dimblk1, DIMBLK1); + handle!(dimblk2, DIMBLK2); + } + let style = effective_style.as_ref(); let scale = style .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) .unwrap_or(1.0); @@ -630,7 +686,15 @@ fn dim_metrics(dim: &Dimension, doc: &CadDocument) -> DimMetrics { let t = ArrowKind::Tick { size: dimtsz as f32 }; (t.clone(), t) } else if let Some(s) = style { - if s.dimsah { + if matches!(dim, Dimension::Diameter(_)) { + use crate::entities::dim_override as ov; + let data = &dim.base().common.extended_data; + let first = ov::handle(data, ov::DIMBLK1) + .unwrap_or(if s.dimsah { s.dimblk1 } else { s.dimblk }); + let second = ov::handle(data, ov::DIMBLK2) + .unwrap_or(if s.dimsah { s.dimblk2 } else { s.dimblk }); + (arrow_from_block(doc, first, asz), arrow_from_block(doc, second, asz)) + } else if s.dimsah { (arrow_from_block(doc, s.dimblk1, asz), arrow_from_block(doc, s.dimblk2, asz)) } else { let a = arrow_from_block(doc, s.dimblk, asz); @@ -993,27 +1057,103 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { result.extend(dim_center_mark(center, met.dimcen, len, &dim_c)); } Dimension::Diameter(d) => { - // Full diameter through the centre (far edge -> near edge), inward - // terminators at both edges, plus the centre mark. - let (center, edge) = (d.angle_vertex, d.definition_point); - let far = v3(2.0 * center.x - edge.x, 2.0 * center.y - edge.y, edge.z); - result.push(make_seg(&far, &edge, &dim_c)); + // The stored points are the two opposite chord points. Their + // midpoint is the measured circle centre. + let (edge, far) = (d.angle_vertex, d.definition_point); + let center = v3( + (edge.x + far.x) * 0.5, + (edge.y + far.y) * 0.5, + (edge.z + far.z) * 0.5, + ); let len = ((edge.x - far.x).powi(2) + (edge.y - far.y).powi(2)) .sqrt() .max(1e-12); let (ux, uy) = ((edge.x - far.x) / len, (edge.y - far.y) / len); - result.extend(dim_terminator(edge, -ux, -uy, &met.arrow1, &dim_c)); - result.extend(dim_terminator(far, ux, uy, &met.arrow2, &dim_c)); - // Optional leader past the near edge toward the text. DIM-DIA-LEADER. - if d.leader_length.abs() > 1e-9 { - let anchor = dim_text_anchor(base, center, edge); - let ld = norm2(anchor.x - edge.x, anchor.y - edge.y, ux, uy); + let ticks = met.dimtsz > 1e-9; + let extension = if ticks { met.dimdle } else { 0.0 }; + let edge_outer = v3( + edge.x + ux * extension, + edge.y + uy * extension, + edge.z, + ); + let far_outer = v3( + far.x - ux * extension, + far.y - uy * extension, + far.z, + ); + if !met.dimsd1 { + result.push(make_seg(&edge_outer, ¢er, &dim_c)); + } + if !met.dimsd2 { + result.push(make_seg(¢er, &far_outer, &dim_c)); + } + let outside = !ticks && met.dimasz > 1e-6 && len < 2.0 * met.dimasz; + if outside { + result.extend(dim_terminator(edge, ux, uy, &met.arrow1, &dim_c)); + result.extend(dim_terminator(far, -ux, -uy, &met.arrow2, &dim_c)); + if !met.dimsoxd { + let stub = 2.0 * met.dimasz; + if !met.dimsd1 { + result.push(make_seg( + &edge, + &v3(edge.x + ux * stub, edge.y + uy * stub, edge.z), + &dim_c, + )); + } + if !met.dimsd2 { + result.push(make_seg( + &far, + &v3(far.x - ux * stub, far.y - uy * stub, far.z), + &dim_c, + )); + } + } + } else { + result.extend(dim_terminator(edge, -ux, -uy, &met.arrow1, &dim_c)); + result.extend(dim_terminator(far, ux, uy, &met.arrow2, &dim_c)); + } + let anchor = dim_text_anchor(base, center, edge); + let distance_squared = |first: Vector3, second: Vector3| { + (first.x - second.x).powi(2) + + (first.y - second.y).powi(2) + + (first.z - second.z).powi(2) + }; + let (leader_tip, suppressed, fallback) = if distance_squared(anchor, edge) + <= distance_squared(anchor, far) + { + (edge, met.dimsd1, (ux, uy)) + } else { + (far, met.dimsd2, (-ux, -uy)) + }; + if !suppressed && d.leader_length.abs() > 1e-9 { + let ld = norm2( + anchor.x - leader_tip.x, + anchor.y - leader_tip.y, + fallback.0, + fallback.1, + ); result.push(make_seg( - &edge, - &v3(edge.x + ld.0 * d.leader_length, edge.y + ld.1 * d.leader_length, edge.z), + &leader_tip, + &v3( + leader_tip.x + ld.0 * d.leader_length.abs(), + leader_tip.y + ld.1 * d.leader_length.abs(), + leader_tip.z, + ), &dim_c, )); } + if !met.dimse1 { + if let Some(points) = crate::scene::dimension_assoc::radial_extension_points( + doc, + base.common.handle, + met.dimexo, + met.dimexe, + ) { + for pair in points.windows(2) { + result.push(make_seg(&pair[0], &pair[1], &ext_c)); + } + } + } result.extend(dim_center_mark(center, met.dimcen, len * 0.5, &dim_c)); } Dimension::Angular2Ln(d) => { @@ -1567,15 +1707,16 @@ mod tests { } // A diameter dimension bakes a line edge-to-edge THROUGH the centre, not a - // radius-length line. The two extreme endpoints must be equidistant from the - // centre (angle_vertex) and the centre must lie between them. + // radius-length line. The two stored extreme endpoints must be equidistant + // from their midpoint and that midpoint must be the circle centre. #[test] fn diameter_dim_bakes_through_center() { use acadrust::entities::DimensionDiameter; let mut doc = CadDocument::new(); let center = Vector3::new(3.0, 4.0, 0.0); let edge = Vector3::new(8.0, 4.0, 0.0); // radius 5 along +x - let mut d = DimensionDiameter::new(center, edge); + let far = Vector3::new(-2.0, 4.0, 0.0); + let mut d = DimensionDiameter::new(edge, far); d.base.text_middle_point = Vector3::new(3.0, 9.0, 0.0); let handle = doc .add_entity(EntityType::Dimension(Dimension::Diameter(d))) From e34b67c709e2fdc16ebf2d6d065c61a31b2be122 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 25 Aug 2026 17:10:55 +0300 Subject: [PATCH 59/98] fix(dimension): complete ordinate workflow --- Cargo.lock | 2 +- Cargo.toml | 4 +- src/app/command_driver.rs | 15 +- src/app/update/command.rs | 13 +- src/command.rs | 2 +- src/entities/dimension.rs | 239 ++++++++++++++++++++++++--- src/modules/annotate/ordinate_dim.rs | 177 ++++++++++++++++---- src/modules/draw/modify/explode.rs | 44 ++++- src/scene/dimension_assoc.rs | 24 ++- 9 files changed, 442 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 808da012..c751ee84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b2ae66#5b2ae66d0bd7b0da2d13392d3c3332f8a39caa1f" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=564859e#564859e77197fdb98193b2477115709e6821229b" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index dc49bfb1..af5833c4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "564859e", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b2ae66" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "564859e" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index d35a3225..4f32d776 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1246,6 +1246,12 @@ impl OpenCADStudio { .document .header .dimension_associativity; + let single_source_dimension = matches!( + &entity, + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Ordinate(_) + ) + ); let pending = if association_mode == 0 { let layer = self.tabs[i].active_layer.clone(); if layer != "0" || entity.as_entity().layer().is_empty() { @@ -1295,7 +1301,13 @@ impl OpenCADStudio { if association_mode == 2 { let sources = source.map_or_else( || self.tabs[i].scene.infer_dimension_sources(handle), - |source| [Some(source), Some(source)], + |source| { + if single_source_dimension { + [Some(source), None] + } else { + [Some(source), Some(source)] + } + }, ); self.tabs[i] .scene @@ -3066,7 +3078,6 @@ impl OpenCADStudio { stretched |= mv(&mut d.definition_point); } Dimension::Ordinate(d) => { - stretched |= mv(&mut d.definition_point); stretched |= mv(&mut d.feature_location); stretched |= mv(&mut d.leader_endpoint); } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index ab25667d..8b753705 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1154,10 +1154,17 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .zip(self.tabs[i].selected_grips.iter()) .find(|(owner, g)| **owner == popup.handle && g.id == popup.grip_id) { - // "Move with Leader" drags the whole multileader; the - // others move just the picked grip. + // The synthetic whole-object grip is specific to a + // multileader. Dimension text grips implement their + // own leader relationship and must keep their real ID. + let is_multileader = matches!( + self.tabs[i].scene.document.get_entity(popup.handle), + Some(acadrust::EntityType::MultiLeader(_)) + ); let (grip_id, is_translate) = - if matches!(item.action, GripMenuAction::MoveWithLeader) { + if matches!(item.action, GripMenuAction::MoveWithLeader) + && is_multileader + { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { (popup.grip_id, g.is_midpoint) diff --git a/src/command.rs b/src/command.rs index 797b27b4..a79db230 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1212,7 +1212,7 @@ pub enum CmdResult { CommitEntitiesAndExit(Vec), /// Commit an acadrust entity to the document and end the command. CommitAndExit(EntityType), - /// Commit a linear/aligned dimension using the drawing's association mode. + /// Commit a dimension using the drawing's association mode. /// Object-selection workflows may retain their source. CommitDimension { entity: EntityType, diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index ae303266..b49fde1a 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -124,6 +124,23 @@ fn properties(dim: &Dimension) -> Vec { ], }]; } + if let Dimension::Ordinate(d) = dim { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![ + Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: PropValue::PlainText(d.base.style_name.clone()), + }, + edit_angle( + t!("Rotation").as_ref(), + "ordinate_rotation", + -d.base.horizontal_direction.to_degrees(), + ), + ], + }]; + } let mut props = base_props(dim.base()); match dim { Dimension::Aligned(d) => { @@ -351,6 +368,40 @@ fn assign_deg(value: &str, target: &mut f64) -> bool { } fn apply_geom_prop(dim: &mut Dimension, field: &str, value: &str) { + let ordinate_auto_text = match dim { + Dimension::Ordinate(ordinate) if !ordinate.base.text_user_positioned => { + Some(dimension_text_pos_f64(dim, None, 2.5, 1.0)) + } + _ => None, + }; + if let Dimension::Ordinate(ordinate) = dim { + match field { + "ordinate_rotation" => { + if let Some(angle) = parse_f64(value) { + ordinate.base.horizontal_direction = -angle.to_radians(); + ordinate.refresh_measurement(); + } + return; + } + "text_x" | "text_y" => { + let old_text = ordinate_auto_text.unwrap_or(ordinate.base.text_middle_point); + let mut new_text = old_text; + let changed = if field == "text_x" { + assign_f64(value, &mut new_text.x) + } else { + assign_f64(value, &mut new_text.y) + }; + if changed { + let delta = new_text - old_text; + ordinate.leader_endpoint = ordinate.leader_endpoint + delta; + ordinate.base.text_middle_point = new_text; + ordinate.base.text_user_positioned = true; + } + return; + } + _ => {} + } + } if apply_base_prop(dim.base_mut(), field, value) { return; } @@ -709,6 +760,42 @@ fn apply_large_radial_fields(d: &mut DimensionLargeRadial, field: &str, value: & } fn apply_transform(dim: &mut Dimension, t: &EntityTransform) { + if matches!(dim, Dimension::Ordinate(_)) { + match t { + EntityTransform::Translate(delta) => dim.translate(Vector3::new( + delta.x, delta.y, delta.z, + )), + EntityTransform::Rotate { + center, + axis, + angle_rad, + } => crate::scene::view::transform::apply_standard_transform( + dim, + *center, + *axis, + *angle_rad, + ), + EntityTransform::Scale { center, factor } => { + crate::scene::view::transform::apply_standard_scale(dim, *center, *factor) + } + EntityTransform::Mirror { + p1, + p2, + working_normal, + } => acadrust::Entity::apply_transform( + dim, + &crate::scene::view::transform::reflection_about_working_line( + *p1, + *p2, + *working_normal, + ), + ), + EntityTransform::Affine(transform) => { + acadrust::Entity::apply_transform(dim, transform) + } + } + return; + } match t { EntityTransform::Translate(d) => dim.translate(acadrust::types::Vector3::new( d.x as f64, d.y as f64, d.z as f64, @@ -1193,10 +1280,9 @@ impl Grippable for Dimension { center_grip(4, text), ], Dimension::Ordinate(d) => vec![ - square_grip(0, dv3(&d.definition_point)), - center_grip(1, dv3(&d.feature_location)), - center_grip(2, dv3(&d.leader_endpoint)), - center_grip(3, text), + square_grip(0, dv3(&d.feature_location)), + center_grip(1, dv3(&d.leader_endpoint)), + center_grip(2, text), ], Dimension::Arc(d) => { let mut grips = vec![ @@ -1227,12 +1313,25 @@ impl Grippable for Dimension { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if matches!(self, Dimension::Ordinate(_)) && grip_id == 2 { + let old_text = dimension_text_pos_f64(self, None, 2.5, 1.0); + let mut new_text = old_text; + apply_to_v3(&mut new_text, &apply); + let delta = new_text - old_text; + if let Dimension::Ordinate(d) = self { + d.leader_endpoint = d.leader_endpoint + delta; + d.base.text_middle_point = new_text; + d.base.text_user_positioned = true; + d.refresh_measurement(); + } + return; + } // Last grip always moves the text. let text_grip = match self { Dimension::Linear(_) | Dimension::Aligned(_) => 3, Dimension::Radius(_) | Dimension::Diameter(_) => 2, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => 4, - Dimension::Ordinate(_) => 3, + Dimension::Ordinate(_) => 2, Dimension::Arc(d) => if d.has_leader { 6 } else { 4 }, Dimension::LargeRadial(_) => 4, }; @@ -1288,9 +1387,15 @@ impl Grippable for Dimension { _ => {} }, Dimension::Ordinate(d) => match grip_id { - 0 => apply_to_v3(&mut d.definition_point, &apply), - 1 => apply_to_v3(&mut d.feature_location, &apply), - 2 => apply_to_v3(&mut d.leader_endpoint, &apply), + 0 => apply_to_v3(&mut d.feature_location, &apply), + 1 => { + let old = d.leader_endpoint; + apply_to_v3(&mut d.leader_endpoint, &apply); + if d.base.text_user_positioned { + d.base.text_middle_point = + d.base.text_middle_point + (d.leader_endpoint - old); + } + } _ => {} }, Dimension::Arc(d) => match grip_id { @@ -1326,7 +1431,7 @@ impl Grippable for Dimension { Dimension::Linear(_) | Dimension::Aligned(_) => (2, 3), Dimension::Radius(_) | Dimension::Diameter(_) => (1, 2), Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => (3, 4), - Dimension::Ordinate(_) => (0, 3), + Dimension::Ordinate(_) => (1, 2), Dimension::Arc(d) => (3, if d.has_leader { 6 } else { 4 }), Dimension::LargeRadial(_) => (3, 4), }; @@ -1365,6 +1470,11 @@ impl Grippable for Dimension { action: GripMenuAction::Center, }, ] + } else if matches!(self, Dimension::Ordinate(_)) { + vec![GripMenuItem { + label: "Stretch", + action: GripMenuAction::Stretch, + }] } else if grip_id == dim_line_grip { vec![ GripMenuItem { @@ -1394,7 +1504,7 @@ impl Grippable for Dimension { Dimension::Linear(_) | Dimension::Aligned(_) => (2, 3), Dimension::Radius(_) | Dimension::Diameter(_) => (1, 2), Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => (3, 4), - Dimension::Ordinate(_) => (0, 3), + Dimension::Ordinate(_) => (1, 2), Dimension::Arc(d) => (3, if d.has_leader { 6 } else { 4 }), Dimension::LargeRadial(_) => (3, 4), }; @@ -1441,7 +1551,7 @@ impl Grippable for Dimension { Dimension::Linear(_) | Dimension::Aligned(_) => 3, Dimension::Radius(_) | Dimension::Diameter(_) => 2, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => 4, - Dimension::Ordinate(_) => 3, + Dimension::Ordinate(_) => 2, Dimension::Arc(d) => if d.has_leader { 6 } else { 4 }, Dimension::LargeRadial(_) => 4, }; @@ -1682,7 +1792,7 @@ pub fn style_sections( _ => choice_value("None", &["None", "Background", "Color"]), }; - vec![ + let mut sections = vec![ PropSection { title: t!("Lines & Arrows").into_owned(), props: vec![ @@ -2312,7 +2422,52 @@ pub fn style_sections( ), ], }, - ] + ]; + if matches!(dimension, Dimension::Ordinate(_)) { + for section in &mut sections { + section.props.retain(|property| match section.title.as_str() { + title if title == t!("Lines & Arrows").as_ref() => matches!( + property.field, + "dim_arrow_size" + | "dim_ext_line_lineweight" + | "dim_ext_line_fixed" + | "dim_ext_line_fixed_length" + | "dim_ext_line_color" + | "dim_ext_line_offset" + ), + title if title == t!("Text").as_ref() => !matches!( + property.field, + "dim_text_outside_align" | "dim_text_pos_hor" | "dim_text_inside_align" + ), + title if title == t!("Fit").as_ref() => matches!( + property.field, + "dim_text_movement" | "dim_scale_overall" + ), + _ => true, + }); + } + let dim_scale = if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }; + let text_position = dimension_text_pos_f64( + dimension, + Some(s), + real(ov::DIMTXT, s.dimtxt) * dim_scale, + dim_scale, + ); + for section in &mut sections { + for property in &mut section.props { + match property.field { + "text_x" => { + property.value = PropValue::EditText(format!("{:.4}", text_position.x)); + } + "text_y" => { + property.value = PropValue::EditText(format!("{:.4}", text_position.y)); + } + _ => {} + } + } + } + } + sections } fn property(label: &str, field: &'static str, value: PropValue) -> Property { @@ -3640,16 +3795,23 @@ fn dimension_geometry( ); } Dimension::Ordinate(d) => { - add_segment( - &mut g.dim_lines, - lv(d.feature_location), - lv(d.definition_point), - ); - add_segment( - &mut g.dim_lines, - lv(d.definition_point), - lv(d.leader_endpoint), - ); + if !suppress.ext1 { + let fixed_length = params + .dimfxlon + .then_some(params.dimfxl.max(0.0) as f64); + let points = d.leader_polyline( + (params.arrow_len * 2.0) as f64, + params.dimexo as f64, + fixed_length, + ); + for pair in points.windows(2) { + let start = lv(pair[0]); + let end = lv(pair[1]); + if (end - start).length_squared() > 1e-12 { + add_segment(&mut g.ext_lines, start, end); + } + } + } } Dimension::Arc(d) => { append_angular_dimension( @@ -4061,7 +4223,6 @@ fn dimension_snap_pts(dim: &Dimension) -> Vec<(glam::DVec3, SnapHint)> { node(d.definition_point), ], Dimension::Ordinate(d) => vec![ - node(d.definition_point), node(d.feature_location), node(d.leader_endpoint), ], @@ -4271,6 +4432,8 @@ fn dimension_text_rotation(dim: &Dimension, style: Option<&DimStyle>) -> f64 { let outside = dimension_text_is_outside(dim, style); if base.text_rotation.abs() > 1e-9 { base.text_rotation + } else if matches!(dim, Dimension::Ordinate(_)) { + dimension_text_natural_rotation(dim) } else if base.horizontal_direction.abs() > 1e-9 { base.horizontal_direction } else if (outside && dimtoh) || (!outside && dimtih) { @@ -4338,6 +4501,14 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let dy = d.second_point.y - d.first_point.y; dy.atan2(dx) } + Dimension::Ordinate(d) => { + let axis_rotation = -d.base.horizontal_direction; + if d.is_ordinate_type_x { + axis_rotation + std::f64::consts::FRAC_PI_2 + } else { + axis_rotation + } + } _ => 0.0, }; // Clamp to (-π/2, π/2] so text never appears upside-down. @@ -5119,6 +5290,26 @@ fn dimension_text_pos_f64( dimtad, ) } + Dimension::Ordinate(d) => { + let (x_axis, y_axis) = d.local_axes(); + let text_axis = if d.is_ordinate_type_x { y_axis } else { x_axis }; + let perpendicular = if d.is_ordinate_type_x { x_axis } else { y_axis }; + let delta = d.leader_endpoint - d.feature_location; + let direction_sign = if delta.dot(&text_axis) < 0.0 { -1.0 } else { 1.0 }; + let perpendicular_sign = if delta.dot(&perpendicular) < 0.0 { + -1.0 + } else { + 1.0 + }; + let vertical = match dimtad { + 0 => dimtvp * text_height, + 4 => -perp_off, + _ => perp_off, + }; + d.leader_endpoint + + text_axis * (direction_sign * (text_w * 0.5 + dimgap)) + + perpendicular * (perpendicular_sign * vertical) + } _ => { // Non-linear (radius / diameter / angular / ordinate): lift the // natural mid point straight up by the style offset. A user-dragged diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index 203659bf..6370466c 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -32,9 +32,22 @@ enum Step { LeaderEndpoint { feature: DVec3 }, } +#[derive(Clone, Copy)] +enum DatumMode { + Automatic, + X, + Y, +} + pub struct OrdinateDimCommand { step: Step, plane: WorkingPlane, + datum_mode: DatumMode, + text_override: Option, + awaiting_text: bool, + mtext_override: bool, + text_angle: Option, + awaiting_angle: bool, } impl OrdinateDimCommand { @@ -42,6 +55,12 @@ impl OrdinateDimCommand { Self { step: Step::FeaturePoint, plane: WorkingPlane::default(), + datum_mode: DatumMode::Automatic, + text_override: None, + awaiting_text: false, + mtext_override: false, + text_angle: None, + awaiting_angle: false, } } } @@ -56,9 +75,23 @@ impl CadCommand for OrdinateDimCommand { } fn prompt(&self) -> String { + if self.awaiting_text { + return if self.mtext_override { + t!("DIMORDINATE Enter formatted dimension text (blank = measured value):") + .into_owned() + } else { + t!("DIMORDINATE Enter dimension text (blank = measured value):").into_owned() + }; + } + if self.awaiting_angle { + return t!("DIMORDINATE Specify text angle (degrees):").into_owned(); + } match self.step { Step::FeaturePoint => t!("DIMORDINATE Specify feature location:").into_owned(), - Step::LeaderEndpoint { .. } => t!("DIMORDINATE Specify leader endpoint:").into_owned(), + Step::LeaderEndpoint { .. } => t!( + "DIMORDINATE Specify leader endpoint [Xdatum/Ydatum/Mtext/Text/Angle]:" + ) + .into_owned(), } } @@ -71,26 +104,105 @@ impl CadCommand for OrdinateDimCommand { Step::LeaderEndpoint { feature } => { let feature = self.plane.to_local(feature); let pt = self.plane.to_local(pt); - let is_x = is_x_type(feature, pt); - let elbow = ordinate_elbow(feature, pt, is_x); + let is_x = match self.datum_mode { + DatumMode::Automatic => is_x_type(feature, pt), + DatumMode::X => true, + DatumMode::Y => false, + }; let mut dim = DimensionOrdinate::new(v3(feature), v3(pt), is_x); - // The leader is an orthogonal L from the feature to the - // endpoint; store its elbow as the definition point. Without it - // the renderer draws feature → (0,0,0) → endpoint, kinking the - // leader through the world origin. The old code also worked in - // the wrong (XZ) plane, dropping the Y coordinate. (#150) - dim.definition_point = v3(elbow); - dim.base.definition_point = v3(elbow); - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Dimension( - Dimension::Ordinate(dim), - ))) + crate::entities::dimension::set_dimension_text_override( + &mut dim.base, + self.text_override.clone(), + ); + if let Some(angle) = self.text_angle { + dim.base.text_rotation = angle; + } + dim.refresh_measurement(); + CmdResult::CommitDimension { + entity: self.plane.place_entity(EntityType::Dimension( + Dimension::Ordinate(dim), + )), + source: None, + } } } } fn on_enter(&mut self) -> CmdResult { + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + return CmdResult::NeedPoint; + } CmdResult::Cancel } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + !self.awaiting_text && !self.awaiting_angle + } + + fn wants_text_with_spaces(&self) -> bool { + self.awaiting_text + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_text { + let value = text.trim(); + self.text_override = if value.is_empty() || value == "<>" { + None + } else { + Some(value.to_string()) + }; + self.awaiting_text = false; + return Some(CmdResult::NeedPoint); + } + if self.awaiting_angle { + let value = text.trim(); + self.text_angle = if value.is_empty() { + None + } else { + crate::entities::common::parse_typed_angle(value) + }; + self.awaiting_angle = false; + return Some(CmdResult::NeedPoint); + } + if !matches!(self.step, Step::LeaderEndpoint { .. }) { + return None; + } + match text.trim().to_uppercase().as_str() { + "X" | "XDATUM" => { + self.datum_mode = DatumMode::X; + Some(CmdResult::NeedPoint) + } + "Y" | "YDATUM" => { + self.datum_mode = DatumMode::Y; + Some(CmdResult::NeedPoint) + } + "T" | "TEXT" => { + self.mtext_override = false; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => { + self.mtext_override = true; + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "A" | "ANGLE" => { + self.awaiting_angle = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + fn on_mouse_move(&mut self, pt: DVec3) -> Option { let feature = match self.step { Step::LeaderEndpoint { feature } => feature, @@ -98,17 +210,24 @@ impl CadCommand for OrdinateDimCommand { }; let feature = self.plane.to_local(feature); let pt = self.plane.to_local(pt); - let is_x = is_x_type(feature, pt); - let elbow = ordinate_elbow(feature, pt, is_x); - let feature = self.plane.to_world(feature); - let elbow = self.plane.to_world(elbow); - let pt = self.plane.to_world(pt); + let is_x = match self.datum_mode { + DatumMode::Automatic => is_x_type(feature, pt), + DatumMode::X => true, + DatumMode::Y => false, + }; + let dim = DimensionOrdinate::new(v3(feature), v3(pt), is_x); + let points = dim.leader_polyline(0.44, 0.0, None); // Screen-only rubber band: downcast to f32 at the preview boundary. - Some(preview_wire(vec![ - feature.as_vec3(), - elbow.as_vec3(), - pt.as_vec3(), - ])) + Some(preview_wire( + points + .into_iter() + .map(|point| { + self.plane + .to_world(DVec3::new(point.x, point.y, point.z)) + .as_vec3() + }) + .collect(), + )) } } @@ -122,17 +241,7 @@ fn v3(p: DVec3) -> Vector3 { fn is_x_type(feature: DVec3, leader: DVec3) -> bool { let dx = (leader.x - feature.x).abs(); let dy = (leader.y - feature.y).abs(); - dy >= dx -} - -/// Orthogonal elbow of the ordinate leader: an X-datum runs along Y from the -/// feature then jogs across in X; a Y-datum runs along X then jogs in Y. -fn ordinate_elbow(feature: DVec3, leader: DVec3, is_x: bool) -> DVec3 { - if is_x { - DVec3::new(feature.x, leader.y, feature.z) - } else { - DVec3::new(leader.x, feature.y, feature.z) - } + dy > dx } fn preview_wire(points: Vec) -> WireModel { diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 302033f9..88a6c197 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -851,7 +851,20 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { // Per-element commons so a baked block keeps the style's colours/lineweights: // extension lines (DIMCLRE/DIMLWE), dimension line + arrows + centre mark // (DIMCLRD/DIMLWD), text (DIMCLRT). - let ext_c = dim_common(&base.common, met.dimclre, met.dimlwe); + let mut ext_c = dim_common(&base.common, met.dimclre, met.dimlwe); + let override_data = &base.common.extended_data; + if let Some(color) = crate::entities::dim_override::color( + override_data, + crate::entities::dim_override::DIMCLRE, + ) { + ext_c.color = color; + } + if let Some(lineweight) = crate::entities::dim_override::int( + override_data, + crate::entities::dim_override::DIMLWE, + ) { + ext_c.line_weight = acadrust::types::LineWeight::from_value(lineweight); + } let dim_c = dim_common(&base.common, met.dimclrd, met.dimlwd); let mut result: Vec = Vec::new(); @@ -1041,8 +1054,33 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { )); } Dimension::Ordinate(d) => { - result.push(make_seg(&d.feature_location, &d.definition_point, &dim_c)); - result.push(make_seg(&d.definition_point, &d.leader_endpoint, &dim_c)); + if !met.dimse1 { + use crate::entities::dim_override as ov; + let scale = ov::real(override_data, ov::DIMSCALE) + .filter(|value| *value > 1e-6) + .unwrap_or(1.0); + let dogleg = ov::real(override_data, ov::DIMASZ) + .unwrap_or(met.dimasz / scale) + * scale + * 2.0; + let offset = ov::real(override_data, ov::DIMEXO) + .unwrap_or(met.dimexo / scale) + * scale; + let fixed_on = ov::int(override_data, ov::DIMFXLON) + .map(|value| value != 0) + .unwrap_or(met.dimfxlon); + let fixed_length = fixed_on.then(|| { + ov::real(override_data, ov::DIMFXL) + .unwrap_or(met.dimfxl / scale) + * scale + }); + let points = d.leader_polyline(dogleg, offset, fixed_length); + for pair in points.windows(2) { + if (pair[1] - pair[0]).length() > 1e-12 { + result.push(make_seg(&pair[0], &pair[1], &ext_c)); + } + } + } } Dimension::Arc(d) => { result.extend(angular_block_segs( diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 2814695e..147b20eb 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -115,10 +115,11 @@ fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Opti .copied() } -fn dimension_points(dimension: &Dimension) -> Option<[Vector3; 2]> { +fn dimension_points(dimension: &Dimension) -> Option<[Option; 2]> { match dimension { - Dimension::Linear(linear) => Some([linear.first_point, linear.second_point]), - Dimension::Aligned(aligned) => Some([aligned.first_point, aligned.second_point]), + Dimension::Linear(linear) => Some([Some(linear.first_point), Some(linear.second_point)]), + Dimension::Aligned(aligned) => Some([Some(aligned.first_point), Some(aligned.second_point)]), + Dimension::Ordinate(ordinate) => Some([Some(ordinate.feature_location), None]), _ => None, } } @@ -186,13 +187,14 @@ impl Scene { return; }; let resolved: [Option<(Handle, i32, f64, u8)>; 2] = std::array::from_fn(|index| { + let point = source_data[index]?; let source = sources[index]?; let entity = self.document.get_entity(source)?; - source_marker(entity, source_data[index]).map(|marker| { + source_marker(entity, point).map(|marker| { ( source, marker, - source_parameter(entity, source_data[index]), + source_parameter(entity, point), if matches!(entity, EntityType::Circle(_)) { 10 } else { @@ -230,7 +232,7 @@ impl Scene { marker, parameter, osnap_type, - source_data[index], + source_data[index].expect("resolved dimension point"), )); } } @@ -274,7 +276,7 @@ impl Scene { return [None, None]; }; points.map(|point| { - self.document + point.and_then(|point| self.document .entities() .filter(|entity| entity.common().handle != dimension) .filter_map(|entity| { @@ -283,7 +285,7 @@ impl Scene { }) .filter(|(distance, _)| *distance <= 1e-16) .min_by(|first, second| first.0.total_cmp(&second.0)) - .map(|(_, handle)| handle) + .map(|(_, handle)| handle)) }) } @@ -353,6 +355,12 @@ impl Scene { aligned.base.actual_measurement = aligned.measurement(); aligned.base.definition_point = aligned.definition_point; } + Dimension::Ordinate(ordinate) => { + if let Some(feature) = first { + ordinate.feature_location = feature; + } + ordinate.refresh_measurement(); + } _ => continue, } refreshed.push((association.dimension, ChangeKind::Modified)); From dd466bbf54d1b4052b6edb476ac0e97d4eb34e2c Mon Sep 17 00:00:00 2001 From: piyton Date: Tue, 25 Aug 2026 21:02:56 +0200 Subject: [PATCH 60/98] registry: add MCP Bridge Co-Authored-By: Claude Opus 5 --- plugins/registry.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plugins/registry.json b/plugins/registry.json index 5a1bc54f..a2e4fe8b 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -18,5 +18,10 @@ "repo": "KevinGriffin-new/opencad-landsurvey-plugin", "name": "Land Survey", "description": "Survey points, PNEZD & LandXML import, TIN surfaces, earthwork volumes, COGO, and coordinate transforms (RTS / Helmert / resection)." + }, + { + "repo": "piyton/ocs-mcp-bridge", + "name": "MCP Bridge", + "description": "Read/write bridge for external tools: serves the live selection and accepts draw/remove requests over a local socket (MCP servers, scripts)." } ] From 81de94fdbb1866c924c7b6c7ecbe2618717c5c72 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 25 Aug 2026 22:11:18 +0300 Subject: [PATCH 61/98] fix(zoom): include inserted block extents Use current rendered geometry instead of load-time cluster spans when fitting model extents. Refs #853 --- src/scene/camera_ops.rs | 57 +++++++++-------------------------------- src/scene/mod.rs | 9 ++----- 2 files changed, 14 insertions(+), 52 deletions(-) diff --git a/src/scene/camera_ops.rs b/src/scene/camera_ops.rs index b27c6061..07c9dab4 100644 --- a/src/scene/camera_ops.rs +++ b/src/scene/camera_ops.rs @@ -1335,15 +1335,8 @@ impl Scene { self.annotation_all_visible(), None, ); - // Ray / XLine tessellate as ±DISPLAY_EXTENT display segments - // (entities/ray.rs) — their endpoints are rendering artifacts, not - // drawing extent. A construction line through the drawing defeats - // both rejects below: its centroid sits at the base point (inside - // the consensus cluster), and in a fresh drawing `local_extent_max` - // is still the 1e9 default, so the far points pass the `lim` filter - // too (issue #284). Exclude them up front — an infinite construction - // line should never contribute to ZOOM Extents — and fall back to their - // base points if the drawing holds nothing else. + // Infinite display segments do not contribute to drawing extents. + // Preserve their base points as a fallback for otherwise empty drawings. let mut infinite_base_pts: Vec = Vec::new(); wires.retain(|w| { let is_infinite = Self::handle_from_wire_name(&w.name) @@ -1389,18 +1382,12 @@ impl Scene { return; } - // Per-wire centroid pass — used both for the absolute-magnitude reject - // (`local_extent_max`) and for the IQR-based outlier reject below. - // A wire whose centroid sits far outside the drawing's consensus - // cluster is an orphan (block-defn entity that leaked into MSPACE, - // bogus hatch boundary, Ray/XLine far point) and must not poison the - // bounding box. + // Collect wire centroids for robust outlier rejection. struct WireCent { idx: usize, cx: f32, cy: f32, } - let lim = self.local_extent_max; let mut cents: Vec = Vec::with_capacity(wires.len()); for (idx, wire) in wires.iter().enumerate() { let mut sx = 0.0_f64; @@ -1426,26 +1413,8 @@ impl Scene { return; } - // Robust drawing centre (median centroid). `lim` is a span RELATIVE to - // this centre, so every reject below is distance-from-centre — geometry - // now reaches fit_all as absolute coordinates (no world_offset), which - // at UTM scale are ~5.7e6; an absolute `|x| > lim` test would reject the - // entire drawing and make ZOOM Extents a no-op. - let (mcx, mcy) = { - let mut xs: Vec = cents.iter().map(|c| c.cx).collect(); - let mut ys: Vec = cents.iter().map(|c| c.cy).collect(); - if xs.is_empty() { - (0.0_f32, 0.0_f32) - } else { - xs.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - ys.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - (xs[xs.len() / 2], ys[ys.len() / 2]) - } - }; - - // IQR-based reject only kicks in with enough samples for the quartiles - // to be meaningful. Below that, the centre-relative `lim` filter is the - // only gate (legacy behavior). + // Quartile rejection needs enough samples to be meaningful. With fewer + // wires, every finite visible point belongs to the drawing extents. let (rx_lo, rx_hi, ry_lo, ry_hi) = if cents.len() >= 8 { let mut xs: Vec = cents.iter().map(|c| c.cx).collect(); let mut ys: Vec = cents.iter().map(|c| c.cy).collect(); @@ -1456,17 +1425,18 @@ impl Scene { let q3x = q(&xs, 0.75); let q1y = q(&ys, 0.25); let q3y = q(&ys, 0.75); - // k=10× the inter-quartile span is permissive enough to keep - // legitimate sparse outlying geometry (annotation labels, scattered - // dim leaders) but tight enough to drop a single wire stranded at - // -world_offset. `max(1.0)` guards against a degenerate IQR=0 - // (e.g. all wires at the same centroid). + // Keep sparse annotations while rejecting isolated corrupt wires. const K: f32 = 10.0; let dx = (q3x - q1x).max(1.0) * K; let dy = (q3y - q1y).max(1.0) * K; (q1x - dx, q3x + dx, q1y - dy, q3y + dy) } else { - (mcx - lim, mcx + lim, mcy - lim, mcy + lim) + ( + f32::NEG_INFINITY, + f32::INFINITY, + f32::NEG_INFINITY, + f32::INFINITY, + ) }; let mut min = glam::Vec3::splat(f32::MAX); @@ -1480,9 +1450,6 @@ impl Scene { if !x.is_finite() || !y.is_finite() || !z.is_finite() { continue; } - if (x - mcx).abs() > lim || (y - mcy).abs() > lim { - continue; - } min = min.min(glam::Vec3::new(x, y, z)); max = max.max(glam::Vec3::new(x, y, z)); } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index cf14edc6..f706fc99 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1657,14 +1657,9 @@ pub struct Scene { pub bg_color: [f32; 4], /// Custom paper-space background fill color for Wipeout entities. pub paper_bg_color: [f32; 4], - /// Largest local-space coordinate expected from real geometry, derived from - /// EXTMIN/EXTMAX (10× safety margin). Used by fit_all() to ignore garbage - /// entity coordinates (origin-stuck entities, bad Ray/XLine direction vectors). + /// Dense model-space cluster half-span used for viewport recovery. pub local_extent_max: f32, - /// Robust centre (median of entity centroids) of the dense model-space - /// cluster. Used together with `local_extent_max` to frame a viewport whose - /// saved view is missing — aiming at the raw extents centre would land in - /// the empty gap when a drawing has a second, far cluster. + /// Dense model-space cluster median used for viewport recovery. pub local_center: [f64; 2], /// Current annotation scale (CANNOSCALE equivalent). /// Multiplier applied to Text/MText/Dimension sizes during tessellation. From fd801fac1b98727c054f82c20a4cb11247415b97 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 25 Aug 2026 22:44:51 +0300 Subject: [PATCH 62/98] fix(hatch): render weighted pattern strokes Show styled boundaries only while selected.\n\nRefs #821 --- src/scene/pipeline/hatch_gpu/storage.rs | 4 +--- src/scene/pipeline/hatch_gpu/texture.rs | 7 +++++- src/scene/preview.rs | 13 ++++++----- src/scene/view/render.rs | 31 +++++++++++++++++++++---- src/shaders/hatch.wgsl | 12 ++++++---- src/shaders/hatch_texture.wgsl | 11 +++++---- 6 files changed, 55 insertions(+), 23 deletions(-) diff --git a/src/scene/pipeline/hatch_gpu/storage.rs b/src/scene/pipeline/hatch_gpu/storage.rs index 43a8edd1..950a9892 100644 --- a/src/scene/pipeline/hatch_gpu/storage.rs +++ b/src/scene/pipeline/hatch_gpu/storage.rs @@ -295,9 +295,7 @@ impl StorageHatchBatch { // along-line phase. let perp_step = fam.dy; let along_step = fam.dx; - // Screen-space derivative drives 1-px line width - // in the shader; this stored field is unused. - let line_width = 0.0_f32; + let line_width = h.line_weight_px.max(1.0); let period: f32 = fam.dashes.iter().map(|d| d.abs()).sum(); families.push(LineFamilyGpu { cos_a: fam.angle_deg.to_radians().cos(), diff --git a/src/scene/pipeline/hatch_gpu/texture.rs b/src/scene/pipeline/hatch_gpu/texture.rs index f0d58b25..c777b3bb 100644 --- a/src/scene/pipeline/hatch_gpu/texture.rs +++ b/src/scene/pipeline/hatch_gpu/texture.rs @@ -286,7 +286,12 @@ impl TextureHatch { texels.push([angle_r.cos(), angle_r.sin(), fam.x0, fam.y0]); texels.push([fam.dx, fam.dy, fam.dy, fam.dx]); // Counts as exact f32 (small integers → no denormal/bitcast risk). - texels.push([0.0, period, n_dashes as f32, dash_rel as f32]); + texels.push([ + model.line_weight_px.max(1.0), + period, + n_dashes as f32, + dash_rel as f32, + ]); n_families += 1; } } diff --git a/src/scene/preview.rs b/src/scene/preview.rs index 5c838dc9..10abfa5a 100644 --- a/src/scene/preview.rs +++ b/src/scene/preview.rs @@ -351,10 +351,8 @@ impl Scene { (plain, texts) } - /// Boundary outline wire for a hatch, reconstructed from its cached - /// `HatchModel` (offsets from `world_origin`). Used only for edit previews — - /// the normal render shows the fill, not this outline. - fn hatch_outline_wire(&self, handle: Handle) -> Option { + /// Boundary wire for edit previews and selected hatches. + pub(super) fn hatch_outline_wire(&self, handle: Handle) -> Option { let m = self.hatches.get(&handle)?; Self::hatch_model_outline_wire(handle, m) } @@ -375,12 +373,15 @@ impl Scene { if pts.len() < 2 { return None; } - Some(WireModel::solid_f64( + let mut wire = WireModel::solid_f64( handle.value().to_string(), pts, m.color, false, - )) + ); + wire.aci = m.aci; + wire.line_weight_px = m.line_weight_px; + Some(wire) } /// Fill-only geometry has no resident wire for the normal rollover xray. diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 29a9f25c..2e5fd703 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3076,15 +3076,38 @@ impl Scene { let Some(entity) = self.document.get_entity(handle) else { continue; }; - if !crate::scene::annotative::is_annotative(&self.document, entity) - || !self.resident_entity_visible( + if !self.resident_entity_visible( entity, target_block, Some(&frozen), annotation_scale_handle, true, - ) - { + ) { + continue; + } + + if matches!(entity, EntityType::Hatch(_)) { + if selected { + if let Some(mut wire) = self.hatch_outline_wire(handle) { + let (_, pattern_length, pattern, line_weight_px, aci) = + render_style_for_viewport( + &self.document, + entity, + content_viewport.then_some(inst.handle), + ); + wire.color = WireModel::SELECTED; + wire.selected = true; + wire.pattern_length = pattern_length; + wire.pattern = pattern; + wire.line_weight_px = line_weight_px; + wire.aci = aci; + wires.push(wire); + } + } + continue; + } + + if !crate::scene::annotative::is_annotative(&self.document, entity) { continue; } diff --git a/src/shaders/hatch.wgsl b/src/shaders/hatch.wgsl index f288faee..33c6696b 100644 --- a/src/shaders/hatch.wgsl +++ b/src/shaders/hatch.wgsl @@ -151,7 +151,6 @@ fn check_family( let pz = xz.y - oz; let perp_step = fam.perp_step * scale; - let line_w = abs(fam.line_width * scale); let perp = -px * sin_a + pz * cos_a; let k = round(perp / perp_step); @@ -163,6 +162,8 @@ fn check_family( -ddx_xz.x * sin_a + ddx_xz.y * cos_a, -ddy_xz.x * sin_a + ddy_xz.y * cos_a, )) * 0.5; + let width_px = select(1.0, max(fam.line_width, 1.0), u.lwdisplay_enable > 0.5); + let half_line = half_px * width_px; // World units per screen pixel on each axis — used to light exactly the // one pixel that contains a dot's centre (pixel-snapped, so the dot stays @@ -172,8 +173,8 @@ fn check_family( // A fragment within ~1px of a line may be a dot; everything further out is // empty fill. (A dot's pixel sits on a line, so its perp offset is < 1px.) - if d > half_px * 2.0 { return false; } - if fam.n_dashes == 0u { return d <= half_px; } + if d > max(half_line, half_px * 2.0) { return false; } + if fam.n_dashes == 0u { return d <= half_line; } let along_step = fam.along_step * scale; let period = fam.period * scale; @@ -185,7 +186,7 @@ fn check_family( for (var j = 0u; j < fam.n_dashes; j++) { let sv = dashes[fam.dash_offset + j] * scale; if sv > 0.0 { - if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } + if d <= half_line && t_mod >= pos && t_mod < pos + sv { return true; } pos = pos + sv; } else if sv < 0.0 { pos = pos - sv; @@ -197,7 +198,8 @@ fn check_family( let dtv = (t - pos) - round((t - pos) / period) * period; let owx = -dtv * cos_a + dperp * sin_a; let owy = -dtv * sin_a - dperp * cos_a; - if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } + let dot_half = width_px * 0.5; + if abs(owx / wpx) <= dot_half && abs(owy / wpy) <= dot_half { return true; } } } return false; diff --git a/src/shaders/hatch_texture.wgsl b/src/shaders/hatch_texture.wgsl index c67df0b8..de40284b 100644 --- a/src/shaders/hatch_texture.wgsl +++ b/src/shaders/hatch_texture.wgsl @@ -146,13 +146,15 @@ fn check_family( -ddx_xz.x * sin_a + ddx_xz.y * cos_a, -ddy_xz.x * sin_a + ddy_xz.y * cos_a, )) * 0.5; + let width_px = select(1.0, max(fam.line_width, 1.0), u.lwdisplay_enable > 0.5); + let half_line = half_px * width_px; let wpx = length(vec2(ddx_xz.x, ddy_xz.x)); let wpy = length(vec2(ddx_xz.y, ddy_xz.y)); - if d > half_px * 2.0 { return false; } + if d > max(half_line, half_px * 2.0) { return false; } - if fam.n_dashes == 0u { return d <= half_px; } + if fam.n_dashes == 0u { return d <= half_line; } let along_step = fam.along_step * scale; let period = fam.period * scale; @@ -165,7 +167,7 @@ fn check_family( let idx = fam.dash_off + j; let sv = texel(h.dash_off + idx / 4u)[idx % 4u] * scale; if sv > 0.0 { - if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } + if d <= half_line && t_mod >= pos && t_mod < pos + sv { return true; } pos = pos + sv; } else if sv < 0.0 { pos = pos - sv; @@ -173,7 +175,8 @@ fn check_family( let dtv = (t - pos) - round((t - pos) / period) * period; let owx = -dtv * cos_a + dperp * sin_a; let owy = -dtv * sin_a - dperp * cos_a; - if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } + let dot_half = width_px * 0.5; + if abs(owx / wpx) <= dot_half && abs(owy / wpy) <= dot_half { return true; } } } return false; From 450135960c384a0e4777f8476963e6da056670fa Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 25 Aug 2026 23:05:51 +0300 Subject: [PATCH 63/98] fix(ui): widen linetype dropdown menus Refs #782 --- src/ui/mod.rs | 1 + src/ui/properties.rs | 6 +- src/ui/wide_menu.rs | 159 ++++++++++++++++++++++++++++++++++++++++ src/ui/window/layers.rs | 33 +++++---- 4 files changed, 183 insertions(+), 16 deletions(-) create mode 100644 src/ui/wide_menu.rs diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 6a8c7cd2..d42db301 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -16,6 +16,7 @@ pub mod side_toolbar; pub mod statusbar; pub mod style; pub mod text_util; +pub mod wide_menu; pub mod window; pub mod wrap_bar; diff --git a/src/ui/properties.rs b/src/ui/properties.rs index 5d562dd4..b6d12971 100644 --- a/src/ui/properties.rs +++ b/src/ui/properties.rs @@ -30,6 +30,7 @@ const PATTERN_CARD_W: f32 = 158.0; const PATTERN_PREVIEW_H: f32 = 58.0; const PATTERN_PICKER_W: f32 = 348.0; const PATTERN_PICKER_H: f32 = 720.0; +const LINETYPE_MENU_W: f32 = 220.0; use crate::app::Message; use crate::scene::model::object::{PropSection, PropValue}; @@ -1049,7 +1050,10 @@ impl PropertiesPanel { .on_open(Message::PropColorPickerClose) .width(Length::Fill); - prop_row_widget(label, combo.into()) + prop_row_widget( + label, + crate::ui::wide_menu::wide_menu(combo, LINETYPE_MENU_W), + ) } fn render_choice_row<'a>( diff --git a/src/ui/wide_menu.rs b/src/ui/wide_menu.rs new file mode 100644 index 00000000..f2308dc0 --- /dev/null +++ b/src/ui/wide_menu.rs @@ -0,0 +1,159 @@ +use crate::app::Message; +use iced::advanced::layout::{self, Layout}; +use iced::advanced::widget::{self, tree, Widget}; +use iced::advanced::{mouse, overlay, renderer, Shell}; +use iced::{Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector}; + +pub fn wide_menu<'a>( + content: impl Into>, + menu_width: f32, +) -> Element<'a, Message> { + Element::new(WideMenu { + content: content.into(), + menu_width, + }) +} + +struct WideMenu<'a> { + content: Element<'a, Message>, + menu_width: f32, +} + +#[derive(Default)] +struct State { + menu_layout: layout::Node, +} + +impl Widget for WideMenu<'_> { + fn tag(&self) -> tree::Tag { + tree::Tag::of::() + } + + fn state(&self) -> tree::State { + tree::State::new(State::default()) + } + + fn diff(&mut self, tree: &mut widget::Tree) { + tree.diff_children(std::slice::from_mut(&mut self.content)); + } + + fn size(&self) -> Size { + self.content.as_widget().size() + } + + fn layout( + &mut self, + tree: &mut widget::Tree, + renderer: &Renderer, + limits: &layout::Limits, + ) -> layout::Node { + let content = self.content.as_widget_mut().layout( + &mut tree.children[0], + renderer, + limits, + ); + + layout::Node::with_children(content.size(), vec![content]) + } + + fn operate( + &mut self, + tree: &mut widget::Tree, + layout: Layout<'_>, + renderer: &Renderer, + operation: &mut dyn widget::Operation, + ) { + self.content.as_widget_mut().operate( + &mut tree.children[0], + layout.child(0), + renderer, + operation, + ); + } + + fn update( + &mut self, + tree: &mut widget::Tree, + event: &Event, + layout: Layout<'_>, + cursor: mouse::Cursor, + renderer: &Renderer, + shell: &mut Shell<'_, Message>, + viewport: &Rectangle, + ) { + self.content.as_widget_mut().update( + &mut tree.children[0], + event, + layout.child(0), + cursor, + renderer, + shell, + viewport, + ); + } + + fn mouse_interaction( + &self, + tree: &widget::Tree, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + renderer: &Renderer, + ) -> mouse::Interaction { + self.content.as_widget().mouse_interaction( + &tree.children[0], + layout.child(0), + cursor, + viewport, + renderer, + ) + } + + fn draw( + &self, + tree: &widget::Tree, + renderer: &mut Renderer, + theme: &Theme, + style: &renderer::Style, + layout: Layout<'_>, + cursor: mouse::Cursor, + viewport: &Rectangle, + ) { + self.content.as_widget().draw( + &tree.children[0], + renderer, + theme, + style, + layout.child(0), + cursor, + viewport, + ); + } + + fn overlay<'a>( + &'a mut self, + tree: &'a mut widget::Tree, + layout: Layout<'a>, + renderer: &Renderer, + viewport: &Rectangle, + translation: Vector, + ) -> Option> { + let bounds = layout.child(0).bounds(); + let (state, children) = (&mut tree.state, &mut tree.children); + let state = state.downcast_mut::(); + + state.menu_layout = layout::Node::new(Size::new( + self.menu_width.max(bounds.width), + bounds.height, + )) + .move_to(Point::new(bounds.x, bounds.y)); + + self.content.as_widget_mut().overlay( + &mut children[0], + Layout::new(&state.menu_layout), + renderer, + viewport, + translation, + ) + } +} diff --git a/src/ui/window/layers.rs b/src/ui/window/layers.rs index 11253ee5..9eb9050d 100644 --- a/src/ui/window/layers.rs +++ b/src/ui/window/layers.rs @@ -46,6 +46,7 @@ const ICON_SZ: f32 = ROW_H * 0.62; // ≈16 px at ROW_H=26 const FONT_SZ: f32 = ROW_H * 0.42; // ≈11 px at ROW_H=26 /// Vertical padding for combo_box / text_input so their total height = ROW_H. const COMBO_PAD_V: f32 = (ROW_H - FONT_SZ * 1.3 - 2.0) / 2.0; +const LINETYPE_MENU_W: f32 = 220.0; /// Widget id for the layer-table scrollable, so a freshly created layer can be /// scrolled into view after it is added (#271). pub const LAYER_TABLE_SCROLL_ID: &str = "layer-manager-table-scroll"; @@ -831,22 +832,24 @@ fn layer_row<'a>( art: String::new(), // art comes from combo state items; just match by name }; let lt_cell: Element<'_, Message> = if let Some(state) = lt_combo { - combo_box( - state, - t!("linetype").as_ref(), - Some(&cur_lt_item), - |item: LinetypeItem| Message::LayerLinetypeSet(item.name), + crate::ui::wide_menu::wide_menu( + combo_box( + state, + t!("linetype").as_ref(), + Some(&cur_lt_item), + |item: LinetypeItem| Message::LayerLinetypeSet(item.name), + ) + .size(FONT_SZ) + .padding(Padding { + top: COMBO_PAD_V, + bottom: COMBO_PAD_V, + left: 4.0, + right: 4.0, + }) + .width(Length::Fixed(COL_LT)) + .input_style(combo_input_style), + LINETYPE_MENU_W, ) - .size(FONT_SZ) - .padding(Padding { - top: COMBO_PAD_V, - bottom: COMBO_PAD_V, - left: 4.0, - right: 4.0, - }) - .width(Length::Fixed(COL_LT)) - .input_style(combo_input_style) - .into() } else { text(layer.linetype.as_str()) .size(FONT_SZ) From b2b84283aea85ce3942a0e911cefc41cf8cbb3ad Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 25 Aug 2026 23:21:24 +0300 Subject: [PATCH 64/98] fix(io): trust decoded current layer Remove the application-side CLAYER fallback now that cadcodec resolves the header handle during document construction. Refs #743 --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- src/app/update/file.rs | 34 ++++++---------------------------- 3 files changed, 9 insertions(+), 31 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cbd9f3b6..babff425 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=2aa1181#2aa1181776b7c1848efe58e3d41ed35138ed7455" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9f3cf8e#9f3cf8e26d5a02fb4ad8ea145ca8e04dd6d6f2bc" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 7a0b74ac..02b96ca9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "2aa1181", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "2aa1181" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 10a95940..4cf472e7 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -1187,34 +1187,12 @@ pub(super) fn on_open_file(&mut self) -> Task { self.tabs[i].scene.material_base_dir = path.parent().map(std::path::Path::to_path_buf); self.tabs[i].scene.document = doc; - // DWG stores CLAYER as a layer handle. Resolve that handle back to - // the layer name after opening so the per-tab creation state and - // header name stay in sync. DXF already provides current_layer_name, - // so keep it as a fallback. - let current_layer = { - let doc = &self.tabs[i].scene.document; - - doc.layers - .iter() - .find(|layer| layer.handle == doc.header.current_layer_handle) - .map(|layer| (layer.name.clone(), layer.handle)) - .or_else(|| { - doc.layers - .get(&doc.header.current_layer_name) - .map(|layer| (layer.name.clone(), layer.handle)) - }) - .or_else(|| { - doc.layers - .get("0") - .map(|layer| (layer.name.clone(), layer.handle)) - }) - }; - - if let Some((name, handle)) = current_layer { - self.tabs[i].scene.document.header.current_layer_name = name.clone(); - self.tabs[i].scene.document.header.current_layer_handle = handle; - self.tabs[i].active_layer = name; - } + self.tabs[i].active_layer = self.tabs[i] + .scene + .document + .header + .current_layer_name + .clone(); // A file saved without the built-in Standard styles (foreign // or damaged) gets them re-seeded so nothing dangles (#366). crate::app::style_ops::ensure_standard_styles( From 2ed4a5d8f30d7b719ce2db037438077fa3542d0c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 25 Aug 2026 23:51:11 +0300 Subject: [PATCH 65/98] fix(dimension): hide definition markers Keep direct POINT helpers out of baked dimension pictures while preserving nested arrow block geometry. Refs #864 --- src/scene/convert/tess.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 623d0db7..d249f80b 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -818,7 +818,10 @@ fn tessellate_entity_inner( graph.walk_insert( &insert, h, - |_, _| true, + |sub, context| { + // Direct POINTs are dimension definition markers. + context.insert_path.len() != 1 || !matches!(sub, EntityType::Point(_)) + }, |sub, context| { let has_book_color = view::render::has_resolved_book_color(document, sub); let color_byblock = From 9a8f29ae45faa9df9c8908ebf6c43d98a80c0096 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 00:00:23 +0300 Subject: [PATCH 66/98] fix(print): avoid Windows dispatch hangs Use unique temporary PDFs and keep shell print handlers off the UI thread. Report missing PDF print associations explicitly. Refs #855 --- src/app/update/file.rs | 20 +++++-------- src/io/print_to_printer.rs | 59 +++++++++++++++++++++++++++++--------- 2 files changed, 52 insertions(+), 27 deletions(-) diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 4cf472e7..53162afd 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -2963,13 +2963,7 @@ pub(super) fn on_open_file(&mut self) -> Task { }; let plot_style = self.dialog_plot_style(&dialog); let options = self.plot_print_options(&dialog, Default::default()); - let stamp = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or(0); - let temp_path = std::env::temp_dir().join(format!( - "open_cad_studio_print_all_{stamp}.pdf" - )); + let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); self.close_active_modal(); self.command_line.push_info( @@ -2987,7 +2981,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .map(|printer| format!("Sent {} layouts to printer: {printer}", pages.len())) .map_err(|error| format!("Print failed: {error}")) }; - self.run_print_all_work(dialog.background, work) + self.run_print_all_work(true, work) } } @@ -4150,7 +4144,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // Preview renders to a temp PDF and opens it — never a save dialog, // whatever the output target is. if preview { - let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); + let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); let render_options = Self::pdf_plot_options(&d, wgroup_splits); let work = move || { crate::io::pdf_export::export_pdf( @@ -4166,7 +4160,7 @@ pub(super) fn on_open_file(&mut self) -> Task { // Tested clipped export (opens a save dialog). return Task::done(Message::PlotWindowExport); } - let tmp = std::env::temp_dir().join("open_cad_studio_print.pdf"); + let tmp = crate::io::print_to_printer::temp_pdf_path("print"); let render_options = Self::pdf_plot_options(&d, wgroup_splits); let opts = self.plot_print_options(&d, wgroup_splits); let work = move || { @@ -4178,14 +4172,14 @@ pub(super) fn on_open_file(&mut self) -> Task { .map(|printer| format!("Sent to printer: {printer}")) .map_err(|e| format!("Print failed: {e}")) }; - return self.run_plot_work(d.background, false, work); + return self.run_plot_work(true, false, work); } let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = self.layout_plot_params(); if preview { - let tmp = std::env::temp_dir().join("open_cad_studio_preview.pdf"); + let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); let render_options = Self::pdf_plot_options(&d, group_splits); let work = move || { crate::io::pdf_export::export_pdf( @@ -4224,7 +4218,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .map(|printer| format!("Sent to printer: {printer}")) .map_err(|error| format!("Print failed: {error}")) }; - self.run_plot_work(d.background, false, work) + self.run_plot_work(true, false, work) } /// Build a [`PrintOptions`](crate::io::print_to_printer::PrintOptions) from diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 61084e33..648e4162 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -13,6 +13,22 @@ use crate::io::plot_style::PlotStyleTable; use crate::scene::model::hatch_model::HatchModel; use crate::io::pdf_export::PlotWire; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_ID: AtomicU64 = AtomicU64::new(0); + let id = NEXT_ID.fetch_add(1, Ordering::Relaxed); + let stamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "open_cad_studio_{kind}_{}_{stamp}_{id}.pdf", + std::process::id() + )) +} + /// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp` /// flags / `-o` options. On Windows the generated PDF already carries render /// options. Windows queues repeated jobs when more than one copy is requested; @@ -166,7 +182,7 @@ pub async fn print_wires_with( plot_style: Option, opts: PrintOptions, ) -> Result { - let tmp_path = std::env::temp_dir().join("open_cad_studio_print.pdf"); + let tmp_path = temp_pdf_path("print"); pdf_export::export_pdf( &wires, &hatches, @@ -228,10 +244,15 @@ fn dispatch_to_printer_opts( ) -> Result { #[cfg(target_os = "windows")] { - // Target a named printer via the "printto" verb; fall back to the - // default-printer "print" verb. use std::ffi::OsStr; use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Foundation::{GetLastError, ERROR_NO_ASSOCIATION}; + use windows_sys::Win32::UI::Shell::{ + ShellExecuteExW, SHELLEXECUTEINFOW, SEE_MASK_FLAG_NO_UI, SEE_MASK_NOASYNC, + SE_ERR_NOASSOC, + }; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE; + let wide = |s: &str| -> Vec { OsStr::new(s).encode_wide().chain(Some(0)).collect() }; let path_wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect(); let (verb, params, label) = match opts.printer.as_deref() { @@ -240,18 +261,28 @@ fn dispatch_to_printer_opts( }; let params_ptr = params.as_ref().map(|v| v.as_ptr()).unwrap_or(std::ptr::null()); for _ in 0..opts.copies.max(1) { - let result = unsafe { - windows_sys::Win32::UI::Shell::ShellExecuteW( - std::ptr::null_mut(), - verb.as_ptr(), - path_wide.as_ptr(), - params_ptr, - std::ptr::null(), - windows_sys::Win32::UI::WindowsAndMessaging::SW_HIDE, - ) as usize + let mut info = SHELLEXECUTEINFOW { + cbSize: std::mem::size_of::() as u32, + fMask: SEE_MASK_FLAG_NO_UI | SEE_MASK_NOASYNC, + lpVerb: verb.as_ptr(), + lpFile: path_wide.as_ptr(), + lpParameters: params_ptr, + nShow: SW_HIDE, + ..Default::default() }; - if result <= 32 { - return Err(format!("ShellExecute failed (code {result})")); + if unsafe { ShellExecuteExW(&mut info) } == 0 { + let shell_code = info.hInstApp as usize; + let code = if (1..=32).contains(&shell_code) { + shell_code as u32 + } else { + unsafe { GetLastError() } + }; + if code == SE_ERR_NOASSOC || code == ERROR_NO_ASSOCIATION { + return Err( + "Windows has no PDF application registered with Print support.".into(), + ); + } + return Err(format!("Windows print dispatch failed (code {code})")); } } Ok(label) From f20bf93dfe3dce5d7c6cd8248341c89fba02e304 Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:31:27 +0200 Subject: [PATCH 67/98] feat(plugin): emit SelectionChangedV4 to V4 plugins and add ocs_plugin_api architecture docs - Add HostNotification::SelectionChangedV4 { tab_id, handles } with discriminant 7 - Broadcast active-tab selection changes at update() and automation_op boundaries - Use stable order-independent signature to avoid spurious hover notifications - Clear Scene selection in automation new/open to avoid stale handles - Add crates/ocs_plugin_api/ARCHITECTURE.md and module-level rustdoc - Cross-link README.md and docs/plugin-architecture.md - Fix test env race with shared ENV_LOCK and EnvVarGuard helpers --- crates/ocs_plugin_api/ARCHITECTURE.md | 261 +++++++++++++++++++ crates/ocs_plugin_api/README.md | 10 +- crates/ocs_plugin_api/src/host.rs | 12 + crates/ocs_plugin_api/src/host_v4.rs | 6 + crates/ocs_plugin_api/src/ipc/mod.rs | 15 ++ crates/ocs_plugin_api/src/ipc/protocol.rs | 10 + crates/ocs_plugin_api/src/ipc/transport.rs | 8 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 4 +- crates/ocs_plugin_api/src/ipc/v4/mod.rs | 9 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 19 ++ crates/ocs_plugin_api/src/lib.rs | 21 ++ crates/ocs_plugin_api/src/manifest.rs | 18 +- crates/ocs_plugin_api/src/process.rs | 21 +- crates/ocs_plugin_api/src/process/manager.rs | 15 ++ crates/ocs_plugin_api/src/process/v4.rs | 23 +- crates/ocs_plugin_api/src/runner.rs | 12 + crates/ocs_plugin_api/src/shm.rs | 15 ++ crates/ocs_plugin_api/src/type_registry.rs | 10 +- crates/ocs_plugin_api/src/version_info.rs | 12 + docs/plugin-architecture.md | 4 +- src/app/automation.rs | 13 + src/app/mod.rs | 6 + src/app/update/mod.rs | 30 +++ src/plugin/v4_support.rs | 9 + src/scene/selection.rs | 46 ++++ 25 files changed, 592 insertions(+), 17 deletions(-) create mode 100644 crates/ocs_plugin_api/ARCHITECTURE.md diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md new file mode 100644 index 00000000..19388eb7 --- /dev/null +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -0,0 +1,261 @@ +# `ocs_plugin_api` — Internal Architecture + +Internal-facing architecture documentation for `crates/ocs_plugin_api`. For plugin-author tutorials see [`README.md`](./README.md); for host/plugin integration design goals see [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md). + +--- + +## Overview + +`ocs_plugin_api` is the stable, semver-versioned contract between the Open CAD Studio host and add-on plugins. The crate is intentionally split into two tiers: + +| Tier | Feature | Dependencies | Purpose | +|------|---------|--------------|---------| +| **Core** | default | none | `PluginManifest`, `ApiVersion`, ribbon vocabulary, type registry, version info. Engine crates and external tooling can depend on this cheaply. | +| **Runtime host surface** | `host` | `acadrust`, `interprocess`, `memmap2`, `rkyv`, etc. | `HostApi`, `BuiltinPlugin`, out-of-process spawn, IPC, shared-memory snapshots, runner. | + +The host and each plugin run in separate OS processes. The host re-executes itself in `--ocs-plugin-runner` mode, the runner dynamically loads the plugin `cdylib`, and the two sides talk over a local socket. Process isolation keeps plugin crashes from affecting the host or other plugins. + +--- + +## Module map + +| Module | Responsibility | +|--------|----------------| +| [`src/manifest.rs`](src/manifest.rs) | `API_VERSION`, `API_VERSION_MIN_SUPPORTED`, `ApiVersion` compatibility, runtime `OCS_PLUGIN_MAX_API_VERSION` gate. | +| [`src/ribbon`](src/ribbon) | `CadModule` trait and plain-data ribbon types (`RibbonGroup`, `ToolDef`, `IconKind`, `ModuleEvent`). | +| [`src/host.rs`](src/host.rs) | `HostApi` plugin-facing runtime trait, `BuiltinPlugin` entry-point trait, `export_plugin!` macro, `HostNotification`, `PluginNotification`. | +| [`src/ipc/mod.rs`](src/ipc/mod.rs) | IPC layer public exports. | +| [`src/ipc/protocol.rs`](src/ipc/protocol.rs) | V2/V3 request/response enums (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`), `RunnerHandshake`, `PLUGIN_TOKEN_ENV`. | +| [`src/ipc/transport.rs`](src/ipc/transport.rs) | Length-framed bincode send/recv over `interprocess::local_socket::Stream`. | +| [`src/ipc/client.rs`](src/ipc/client.rs) | Plugin-side V2/V3 `IpcClient` and `PluginHostApi` proxy. | +| [`src/ipc/server.rs`](src/ipc/server.rs) | Host-side handler that applies a `PluginRequest` to a `HostApi` implementation. | +| [`src/ipc/v4`](src/ipc/v4) | Multiplexed V4 protocol: frames, notifications, correlation ids. | +| [`src/ipc/proxy.rs`](src/ipc/proxy.rs) | Optional TCP request proxy for plugin child processes. | +| [`src/process.rs`](src/process.rs) | `PluginProcess` lifecycle: spawn, handshake, call, timeouts, `NullHost`, io draining. | +| [`src/process/manager.rs`](src/process/manager.rs) | `PluginManager`: owns loaded plugins, dispatch routing, notification broadcast, V4 gating. | +| [`src/process/v4.rs`](src/process/v4.rs) | Host-side V4 connection and reader thread. | +| [`src/runner.rs`](src/runner.rs) | Runner entry point and V2/V3 vs V4 request loops; loads cdylib via `libloading`. | +| [`src/shm.rs`](src/shm.rs) | Shared-memory document snapshot layout, `DocumentSnapshotStore`, `SharedDocumentReader`, V3/V4 view data. | +| [`src/host_v4.rs`](src/host_v4.rs) | Host-side per-tab V4 snapshot manager. | +| [`src/type_registry.rs`](src/type_registry.rs) / [`src/type_registry_types.rs`](src/type_registry_types.rs) | Build-time `serde-reflection` registry, embedded JSON, schema types. | +| [`src/version_info.rs`](src/version_info.rs) | Embedded version metadata and acadrust source-compatibility helper. | + +--- + +## Versioning and ABI stability + +- `API_VERSION` (currently `4`) is the host's advertised major. +- `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads. +- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `3` to disable V4). +- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end. +- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)). + +The runtime enforces two gates: + +1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`. +2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true. + +--- + +## Process model + +```mermaid +sequenceDiagram + participant H as Host process + participant PM as PluginManager + participant PP as PluginProcess + participant R as Runner child + participant L as Plugin cdylib + + PM->>PP: spawn(cdylib_path, host) + PP->>H: spawn --ocs-plugin-runner + H->>R: exec + R->>R: create local socket listener + R->>L: unsafe { load(cdylib_path) } + L-->>R: Box + R->>PP: connect + RunnerHandshake::Token + PP->>PP: verify OCS_PLUGIN_TOKEN + PP->>R: HostToPlugin::Request(GetManifest) + R->>L: manifest() + L-->>R: PluginManifest { api_version } + R-->>PP: HostResponse::Manifest + PP->>PP: verify api_version + acadrust gate + alt accepted + PP-->>PM: success, keep alive + else rejected + PP->>R: Shutdown + PP-->>PM: error + end +``` + +Key invariants: + +- The runner process is the host binary re-executed with special CLI args, so runner and host share the same `ocs_plugin_api` build. +- The plugin cdylib is `dlopen`-ed inside the runner, not the host. +- A pre-shared token (`OCS_PLUGIN_TOKEN`) authenticates the runner to the host. +- `PluginProcess` owns the `Child` handle and a reader thread; `PluginManager` owns the loaded plugins. + +--- + +## Wire protocols + +### V2/V3: request/response over a single socket + +A single bidirectional socket carries both directions. While the host waits for a response to a `HostRequest`, it may receive nested `PluginRequest`s from the runner and handles them inline. + +```mermaid +sequenceDiagram + participant H as Host + participant R as Runner + participant P as BuiltinPlugin + + H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" }) + R->>P: dispatch(host, "LINE") + P->>R: PluginToHost::Request(AddEntity(...)) + R->>H: HostToPlugin::Request(AddEntity(...)) + H-->>R: PluginToHost::Response(Handle) + R-->>P: PluginResponse::Handle + P-->>R: true + R-->>H: PluginToHost::Response(Bool(true)) +``` + +Compatibility rule: new variants are appended at the end of `HostRequest`, `HostResponse`, `PluginRequest`, and `PluginResponse`, preserving bincode discriminant indices for old plugins. + +### V4: multiplexed frames + +V4 keeps the V3 request vocabulary but multiplexes requests, responses, and best-effort notifications over one local socket. Every request/response carries a correlation `id`; notifications carry an optional `command_id`. + +```mermaid +sequenceDiagram + participant H as Host + participant R as V4 Runner + participant P as BuiltinPlugin + + H->>R: HostToPluginV4::Notification(DocumentChangedV4 { tab_id }) + H->>R: HostToPluginV4::Request { id: 7, ExecuteCode } + R->>P: start_execute_code(host, 7, ...) + P-->>R: true + P->>R: Notification(Output { text: "..." }) + R->>H: PluginToHostV4::Notification(Output) + P->>R: Response { id: 7, result } + R->>H: PluginToHostV4::Response { id: 7, CodeExecutionResult } +``` + +Key types in [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs): + +- `HostToPluginV4`: `Request { id, HostRequest }`, `Response { id, PluginResponse }`, `Notification`. +- `PluginToHostV4`: `Request { id, tab_id, PluginRequest }`, `Response { id, HostResponse }`, `Notification`. +- `NotificationEnvelope`: `{ command_id: Option, payload: T }`. + +Notifications are best-effort: per-process errors are logged, not propagated. Unknown host-notification discriminants deserialize to `HostNotification::Unknown(raw)` so a newer host does not crash an older plugin. + +--- + +## Shared-memory document views + +V3 and V4 avoid cloning the full `CadDocument` over IPC for large reads. The host owns a memory-mapped file; the plugin maps it read-only. + +```mermaid +sequenceDiagram + participant P as Plugin + participant R as Runner + participant H as Host + participant S as DocumentSnapshotStore + + P->>R: request(OpenDocumentView) + R->>H: PluginRequest::OpenDocumentView + H->>S: DocumentSnapshotStore::new / publish + S-->>H: DocumentViewInfo { path, version } + H-->>R: PluginResponse::DocumentView { path, version } + R-->>P: DocumentViewInfo + P->>S: SharedDocumentReader::open(path) + S-->>P: read-only mapping + loop host publishes new version + H->>S: publish(data) + S->>S: swap active segment, increment version + P->>S: read() + check version + end + P->>P: close reader + H->>S: close(tab_id) +``` + +- [`src/shm.rs`](src/shm.rs) defines the generic double-buffered control page and the `SnapshotData` trait. Implementations live in the same file for V3 (`DocumentViewData`) and V4 (`DocumentViewDataV4`). +- [`src/host_v4.rs`](src/host_v4.rs) keeps a global `HostV4SnapshotManager` keyed by `tab_id`, so each document tab has its own snapshot. +- Segment size defaults to 16 MiB and is configurable via `OCS_V4_SNAPSHOT_SEGMENT_SIZE` (minimum 1 MiB). + +--- + +## Notifications + +- **Host → plugin:** `HostNotification` in [`src/host.rs`](src/host.rs). V4-only delivery via `PluginManager::broadcast_notification`. Currently emitted examples: `DocumentChangedV4`, `SelectionChangedV4`, `DocumentTabClosed`. +- **Plugin → host:** `PluginNotification` in [`src/host.rs`](src/host.rs). Examples: `Output`, `Progress`, `Log`. Handler installed via `PluginManager::set_notification_handler`. + +Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 processes are skipped. The handler runs on the V4 reader thread and must not block. + +--- + +## Error handling and timeouts + +| Kind | Default | Env var | Behavior | +|------|---------|---------|----------| +| Spawn / connect timeout | 30 s | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | Host aborts spawn, plugin is not loaded. | +| Per-call timeout | 30 s | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | Host kills runner, plugin marked dead. Floor timeouts apply to `GetManifest`/`GetRibbon`, `Dispatch`, and interactive events. | +| Oversized message | — | — | Transport rejects messages > 64 MiB with `TransportError::TooLarge`. | +| Malformed message | — | — | `bincode` deserialize error; V4 reader logs and continues if possible. | +| Panic in plugin code | — | — | Caught by `catch_unwind` in the runner; converted to `HostResponse::Error`. | +| Dead runner | — | — | Detected via `try_wait` on next dispatch or ribbon rebuild; plugin dropped. | + +--- + +## Embedded metadata + +The crate embeds two JSON blobs at build time: + +- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). +- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). + +Both are accessible without enabling the `host` feature. + +--- + +## Security / isolation boundaries + +- **Process isolation:** plugin code runs in a child process. +- **Token handshake:** `OCS_PLUGIN_TOKEN` is generated per spawn and checked before accepting the runner connection. +- **C-ABI export:** plugins expose only two symbols, `ocs_plugin_api_version` and `ocs_plugin_register`, defined by `export_plugin!`. +- **Message size cap:** 64 MiB per length-framed message. +- **Shared memory:** plugin maps snapshots read-only; the host controls publish/close. + +This is defense-in-depth, not a sandbox: plugins execute native code. + +--- + +## Failure modes + +| Scenario | Result | +|----------|--------| +| Plugin panics during dispatch | Runner catches, returns error response, process stays alive. | +| Call timeout | Host kills runner, plugin marked dead. | +| Spawn timeout or crash | Spawn fails; plugin not loaded; error surfaced in UI/logs. | +| Version mismatch | Host refuses plugin before running plugin code. | +| Acadrust source mismatch (v4+) | Host refuses plugin to avoid binary incompatibilities. | +| Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. | + +--- + +## Open tasks + +- **Reduce broadcast notification clone/serialize overhead.** `PluginManager::broadcast_notification` clones the `HostNotification` (including its `Vec`) once per loaded V4 plugin, and `transport::send` re-serializes the payload into a new `Vec` per plugin. Fixing this properly requires either a public API change (`Arc>` in `HostNotification`) or a new bytes-oriented broadcast path in the IPC layer. It only matters with many plugins plus very large selections. + +--- + +## Cross-references + +- Plugin-author quick start: [`README.md`](./README.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) +- Plugin template: [`../../docs/plugin-template/`](../../docs/plugin-template/) +- Process spawn: [`src/process.rs`](src/process.rs) +- Process manager: [`src/process/manager.rs`](src/process/manager.rs) +- Runner loop: [`src/runner.rs`](src/runner.rs) +- V4 protocol: [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs) +- Shared memory: [`src/shm.rs`](src/shm.rs) diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index 93951816..25021c8e 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -120,9 +120,10 @@ The frame format supports: - Bi-directional, best-effort notifications (`NotificationEnvelope`) Because the socket is full-duplex, the host can push -`HostNotification::DocumentChanged` or `InputLine` to a plugin at any time, and -the plugin can stream `PluginNotification::Output`/`Progress`/`Log` back while a -long command is still running. +`HostNotification::DocumentChangedV4`, `SelectionChangedV4`, or +`DocumentTabClosed` to a plugin at any time, and the plugin can stream +`PluginNotification::Output`/`Progress`/`Log` back while a long command is still +running. ```mermaid sequenceDiagram @@ -420,7 +421,8 @@ std::thread::spawn(move || { ### Further reading +- Internal architecture & invariants: [`ARCHITECTURE.md`](./ARCHITECTURE.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) - Plugin template: [`../../docs/plugin-template`](../../docs/plugin-template) - Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md) - REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md) -- Python REPL plugin: [`../ocs_python_repl/README.md`](../ocs_python_repl/README.md) diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index eb2220b9..f146c23a 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -69,6 +69,8 @@ pub enum HostNotification { DocumentChangedV4 { tab_id: u64, version: u64 }, /// V4 tab closed notification. Discriminant 6. DocumentTabClosed { tab_id: u64 }, + /// V4 selection changed for a specific tab. Discriminant 7. + SelectionChangedV4 { tab_id: u64, handles: Vec }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -112,6 +114,13 @@ impl Serialize for HostNotification { bincode::serialize_into(&mut bytes, tab_id) .map_err(serde::ser::Error::custom)?; } + HostNotification::SelectionChangedV4 { tab_id, handles } => { + bytes.push(7); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, handles) + .map_err(serde::ser::Error::custom)?; + } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) @@ -146,6 +155,9 @@ impl<'de> Deserialize<'de> for HostNotification { 6 => bincode::deserialize(rest) .map(|tab_id| HostNotification::DocumentTabClosed { tab_id }) .map_err(serde::de::Error::custom), + 7 => bincode::deserialize(rest) + .map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles }) + .map_err(serde::de::Error::custom), _ => Ok(HostNotification::Unknown(bytes)), } } diff --git a/crates/ocs_plugin_api/src/host_v4.rs b/crates/ocs_plugin_api/src/host_v4.rs index c6341338..3674ea8f 100644 --- a/crates/ocs_plugin_api/src/host_v4.rs +++ b/crates/ocs_plugin_api/src/host_v4.rs @@ -3,6 +3,12 @@ //! Maintains a per-tab shared-memory document view keyed by the stable //! `DocumentTab.id`. The manager is used by `src/plugin/v4_support.rs` to //! open, publish, and close V4 snapshots independently of the V3 reader path. +//! +//! [`manager`] returns a global `Mutex`-guarded [`HostV4SnapshotManager`]. +//! [`HostV4SnapshotManager::open`] lazily creates a [`DocumentSnapshotStore`] for +//! a tab; [`HostV4SnapshotManager::publish`] updates an existing store; +//! [`HostV4SnapshotManager::close`] drops it. Segment size defaults to 16 MiB +//! and can be overridden with `OCS_V4_SNAPSHOT_SEGMENT_SIZE`. use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index 5ea05e15..44e7e02e 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -2,6 +2,21 @@ //! //! Built only with the `host` feature because it needs `acadrust`-typed //! messages and the plugin runner binary. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::protocol) — V2/V3 request/response enums and the +//! initial runner handshake. +//! - [`transport`](crate::ipc::transport) — length-framed bincode over a local +//! socket; enforces `MAX_MESSAGE_SIZE`. +//! - [`client`](crate::ipc::client) — plugin-side V2/V3 client and `HostApi` +//! proxy. +//! - [`server`](crate::ipc::server) — host-side handler that applies a +//! `PluginRequest` to a `HostApi` implementation. +//! - [`v4`](crate::ipc::v4) — multiplexed V4 frames, notifications, and +//! correlation ids. +//! - [`proxy`](crate::ipc::proxy) — optional TCP proxy so plugin child +//! processes can forward requests to the host. #[cfg(feature = "host")] pub mod client; diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index c8735a30..5b494026 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -4,6 +4,16 @@ //! (expecting a response) or a response (to a previous request). This lets the //! host handle plugin RPCs inline while it waits for the result of a host→plugin //! request such as `Dispatch`, avoiding the need for two sockets or threads. +//! +//! Compatibility rule: new variants are appended at the end of every public enum +//! (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`, +//! `RunnerHandshake`) so older plugins keep their bincode discriminant indices. +//! The V4 additions are a separate frame layer in [`crate::ipc::v4`] and do not +//! alter these enums. +//! +//! The pre-shared runner token is delivered through [`PLUGIN_TOKEN_ENV`] +//! (`OCS_PLUGIN_TOKEN`). The runner must present the same token immediately +//! after connecting or the host closes the connection. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/ipc/transport.rs b/crates/ocs_plugin_api/src/ipc/transport.rs index 69e42ce6..3cc1296d 100644 --- a/crates/ocs_plugin_api/src/ipc/transport.rs +++ b/crates/ocs_plugin_api/src/ipc/transport.rs @@ -1,4 +1,12 @@ //! Length-framed transport over `interprocess::local_socket` streams. +//! +//! Messages are serialized with `bincode`, prefixed by a little-endian `u64` +//! length, and sent over the stream. The receiver parses the length, bounds it +//! against [`MAX_MESSAGE_SIZE`], then deserializes the payload. +//! +//! [`send`] and [`recv`] are synchronous and block until the full frame is read +//! or the peer disconnects. `Disconnected` is returned on clean EOF; `TooLarge` +//! rejects messages that exceed 64 MiB to protect host/runner memory. use std::io::{Read, Write}; diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 6eda5605..a491a6eb 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -761,7 +761,6 @@ impl DocumentReader for EmptyDocumentReader { #[cfg(all(test, feature = "host"))] mod tests { use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex as StdMutex; use std::thread; use interprocess::local_socket::{ @@ -772,10 +771,9 @@ mod tests { use super::*; use crate::ipc::transport::recv; use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4}; + use crate::test_lock::ENV_LOCK; use acadrust::entities::Point; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - fn unique_socket_name() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); diff --git a/crates/ocs_plugin_api/src/ipc/v4/mod.rs b/crates/ocs_plugin_api/src/ipc/v4/mod.rs index e67788bd..3a27ac7e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/mod.rs @@ -4,6 +4,15 @@ //! requests, responses, and best-effort notifications over a single local //! socket. The V3 files (`src/ipc/{protocol,client,server}.rs`) are left //! untouched; all V4 logic lives in this module. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::v4::protocol) — V4 wire frames, notification +//! envelopes, and correlation ids. +//! - [`client`](crate::ipc::v4::client) — plugin-side V4 client loop and +//! notification dispatch. +//! - [`server`](crate::ipc::v4::server) — host-side V4 connection and reader +//! thread. pub mod client; pub mod protocol; diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 2d339b5d..34e97c2e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -1,4 +1,23 @@ //! V4 wire frames and envelope types. +//! +//! V4 replaces the simple request/response pipe with a multiplexed frame layer. +//! Every request and response carries a monotonically increasing correlation +//! `id` so a plugin can send/receive out of order. Notifications carry an +//! optional `command_id` so a plugin can associate a host notification with the +//! long-running command that caused it. +//! +//! The two frame enums are: +//! +//! - [`HostToPluginV4`] — host → runner requests, responses to runner requests, +//! and host notifications. +//! - [`PluginToHostV4`] — runner → host requests, responses to host requests, +//! and plugin notifications. +//! +//! Notifications are wrapped in [`NotificationEnvelope`] and are best-effort: +//! a process that fails to accept a notification is logged and skipped, but the +//! connection stays alive. Unknown host-notification discriminants deserialize +//! to [`crate::host::HostNotification::Unknown`] so newer host notifications do +//! not break older plugins. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index e721983e..9b430922 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -20,6 +20,17 @@ //! For binary compatibility, the host and every plugin must resolve the same //! `acadrust` source. The host does this via a `[patch.crates-io]` entry in //! `Cargo.toml`; out-of-tree plugins should copy that exact patch. +//! +//! For internal architecture (process model, wire protocols, versioning policy, +//! failure modes), see `ARCHITECTURE.md` in the crate root. The modules enabled +//! by the `host` feature are: +//! +//! - `host` — plugin/runtime traits and notification types. +//! - `host_v4` — per-tab shared-memory snapshot manager. +//! - `ipc` — transport, V2/V3 protocol, and V4 multiplexed protocol. +//! - `process` — `PluginProcess` and `PluginManager`. +//! - `runner` — child-process runner entry point. +//! - `shm` — shared-memory document views. pub mod manifest; pub mod ribbon; @@ -64,5 +75,15 @@ pub use type_registry::{ }; pub use version_info::get_embedded_version_info_json; +#[cfg(test)] +pub(crate) mod test_lock { + //! Shared lock for tests that mutate process environment variables. + //! + //! Environment variables are global mutable state. Any test that sets or + //! removes an env var must hold this lock for the duration of the mutation + //! so tests in other modules do not observe half-written state. + pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +} + #[cfg(feature = "host")] pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess}; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 0d459341..39352e8a 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -1,4 +1,16 @@ -//! Plugin identity and capability declaration. +//! Plugin identity, API version constants, and compatibility rules. +//! +//! This module defines the host ABI major version (`API_VERSION`), the oldest +//! major the host still loads (`API_VERSION_MIN_SUPPORTED`), and the runtime +//! `OCS_PLUGIN_MAX_API_VERSION` gate that lets operators disable newer API +//! majors without rebuilding. +//! +//! Compatibility rule: a plugin compiled against major `N` runs on any host +//! whose major is `>= N`, because new vtable entries and enum variants are +//! appended at the end. `host_accepts_plugin_version` enforces the supported +//! range after applying the runtime cap. +//! +//! See `ARCHITECTURE.md` in the crate root for the full versioning policy. /// Host plugin API version. Bump when the host runtime surface breaks /// compatibility. v2 added `HostApi::start_interactive`. v3 changes @@ -56,12 +68,10 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool { #[cfg(test)] mod tests { - use std::sync::Mutex; + use crate::test_lock::ENV_LOCK; use super::*; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - #[test] fn current_matches_const() { assert_eq!(ApiVersion::CURRENT.major, API_VERSION); diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 9a26d958..7535af35 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -1,4 +1,22 @@ //! Process management for out-of-process plugins. +//! +//! This module defines the lifecycle of a single plugin process: +//! +//! 1. `PluginProcess::spawn` creates a local socket, spawns the host binary in +//! runner mode (`--ocs-plugin-runner `), and waits for the +//! runner to connect back. +//! 2. The runner presents a pre-shared token via [`crate::ipc::protocol::PLUGIN_TOKEN_ENV`]; +//! the host rejects the connection on mismatch. +//! 3. The host requests the manifest, checks `api_version` and (for v4+) the +//! acadrust source gate, then keeps the process alive. +//! 4. Host → plugin calls (`dispatch`, `execute_code`, interactive events) are +//! sent over the socket with a configurable per-call timeout. +//! 5. Stdout/stderr of the child are drained into `PluginIoLine` records for +//! logging and UI display. +//! +//! `NullHost` is a dummy `HostApi` implementation used for V4 paths that do not +//! supply a real host surface (e.g., interactive events). Timeouts and message +//! size limits are centralized here. use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -1119,6 +1137,7 @@ mod timeout_tests { }; use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedPluginManifest; + use crate::test_lock::ENV_LOCK; use acadrust::xdata::ExtendedDataRecord; use acadrust::{CadDocument, EntityType, Handle}; use interprocess::local_socket::{ @@ -1130,8 +1149,6 @@ mod timeout_tests { use std::thread; use std::time::Instant; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - struct EmptyReader; impl DocumentReader for EmptyReader { diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index d2fec1e4..003f6ff0 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -1,4 +1,19 @@ //! Process manager for out-of-process plugins. +//! +//! `PluginManager` is the host's owner of every loaded plugin process. It is +//! responsible for: +//! +//! - Spawning plugins via [`PluginProcess::spawn`] and building their ribbon +//! modules. +//! - Routing commands to plugins through [`PluginManager::dispatch`]. +//! - Broadcasting host notifications to every alive V4 plugin via +//! [`PluginManager::broadcast_notification`]. +//! - Surfacing dead plugins and per-plugin errors in [`DispatchResult`]. +//! +//! Notifications are V4-only: `broadcast_notification` skips non-V4 processes. +//! The plugin-to-host notification handler (set via +//! [`PluginManager::set_notification_handler`]) runs on the V4 reader thread +//! and must not block. For heavy work, forward the notification to a channel. use std::path::Path; use std::sync::Arc; diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 9843c3bb..d2fc6015 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -517,7 +517,7 @@ mod tests { use std::sync::Mutex as StdMutex; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + use crate::test_lock::ENV_LOCK; fn set_test_env() { std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2"); @@ -532,6 +532,13 @@ mod tests { #[test] fn execute_code_timeout_floor_is_at_least_60s_and_env_override() { let _guard = ENV_LOCK.lock().unwrap(); + let prev_call_timeout = std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS").ok(); + let prev_test_floor = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS").ok(); + let prev_execute_timeout = std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS").ok(); + // Remove the test floor bypass and the call-timeout override so the + // ExecuteCode-specific floor (60 s) is actually exercised. + std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"); + std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"); std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); assert!( request_timeout("ExecuteCode") >= Duration::from_secs(60), @@ -544,7 +551,19 @@ mod tests { Duration::from_secs(120), "env override should be respected" ); - std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); + + match prev_call_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"), + } + match prev_test_floor { + Some(v) => std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"), + } + match prev_execute_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"), + } } #[test] diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 6b4a60c6..03f0097f 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -4,6 +4,18 @@ //! (`--ocs-plugin-runner `). Keeping the runner code inside //! `ocs_plugin_api` means the host only needs to know the CLI contract, not the //! internal plugin-loading and IPC details. +//! +//! Entry points and loops: +//! +//! - [`run`] — loads the cdylib with `libloading`, connects back to the host, +//! and dispatches to either the V2/V3 loop or the V4 loop based on the plugin's +//! declared API version. +//! - `run_v3` — synchronous request/response loop over [`crate::ipc::client::IpcClient`]. +//! - `run_v4` — multiplexed frame loop over [`crate::ipc::v4::client::V4Client`]; +//! drains notifications and dispatches host requests. +//! +//! Panics inside plugin callbacks are caught by `catch_unwind` and converted to +//! error responses so a buggy plugin callback does not tear down the runner. use std::cell::RefCell; use std::collections::HashMap; diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index ebada4e5..4203ce40 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -8,6 +8,21 @@ //! This module is generic over the payload type so that the V3 simplified view //! and the V4 bincode-per-entity view can share the same double-buffered //! control-page logic. +//! +//! Key types: +//! +//! - [`DocumentSnapshotStore`] — host-side, file-backed double buffer. Call +//! [`DocumentSnapshotStore::publish`] to atomically swap the active segment and +//! increment the version. +//! - [`SharedDocumentReader`] — plugin-side read-only mapping. +//! - [`DocumentViewInfo`] — path + version returned to the plugin so it can +//! open the mapping. +//! - [`DocumentViewData`] / [`DocumentViewDataV4`] — concrete snapshot payloads +//! for V3 and V4. +//! +//! The control page at the start of the mapping stores magic, version, active +//! segment, and length. The two snapshot segments follow it. Publishing writes +//! to the inactive segment, then flips the active segment atomically. use std::fs::OpenOptions; use std::io; diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs index a563d715..ce55abce 100644 --- a/crates/ocs_plugin_api/src/type_registry.rs +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -3,7 +3,15 @@ //! The JSON embedded here is produced by tracing a curated allow-list of //! `acadrust` types with `serde-reflection` and mapping the result into a //! stable, language-binding-friendly schema defined in -//! [`crate::type_registry_types`]. +//! [`crate::type_registry_types`]. The registry is embedded via +//! `include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"))` so the +//! `host` feature is not required to read it. +//! +//! The allow-list is intentionally narrow: it excludes generic containers and +//! internal enums such as `EntityType` in favor of concrete serializable structs +//! like `Point`, `Line`, `Circle`, and `MText`. Language bindings and tooling +//! can depend on this schema without pulling the full `acadrust` dependency +//! tree. pub use crate::type_registry_types::*; diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 1df0bc30..35c870cd 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -1,4 +1,16 @@ //! Embedded version metadata generated at build time. +//! +//! At compile time `build.rs` writes a small JSON blob to +//! `OUT_DIR/version_info.json` containing host version, `ocs_plugin_api` +//! version, `acadrust` version and source, API version bounds, and a build +//! timestamp. The blob is embedded via `include_str!` and is available without +//! enabling the `host` feature. +//! +//! The acadrust source string is used to detect binary-incompatible plugin +//! builds. For API v4 and later the host compares the plugin's resolved +//! `acadrust` source with its own via [`acadrust_sources_compatible`]. Two +//! sources are considered compatible only when they resolve to the same 40 +//! character git commit hash. use std::sync::OnceLock; diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index b3a5e5a4..dc1a8d0a 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -127,6 +127,7 @@ concrete types: | Command line | `push_info`, `push_output`, `push_error` | | Undo / dirty | `push_undo`, `set_dirty` | | Tab | `tab_index()` | +| Notifications (V4) | `on_notification` receives `HostNotification::SelectionChangedV4 { tab_id, handles }` when the active tab's selection changes, plus `DocumentChangedV4` / `DocumentTabClosed`. | ### `export_plugin!` — the C-ABI export @@ -309,7 +310,7 @@ Two timeouts protect the host from a stuck runner: | Timeout | Env var | Default | Floor | |---|---|---|---| -| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 10 s | — | +| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 30 s | — | | Per-call | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | 30 s | `GetManifest`/`GetRibbon` ≥ 5 s, `Dispatch` ≥ 10 s, interactive events/prompt/pick ≥ 2 s | A call timeout covers the full round-trip, including any nested plugin→host @@ -402,6 +403,7 @@ Next: | Piece | Location | |-------|----------| | Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | +| Internal API architecture | [`crates/ocs_plugin_api/ARCHITECTURE.md`](../crates/ocs_plugin_api/ARCHITECTURE.md) | | Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) | | Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../crates/ocs_plugin_api/src/process.rs) | | Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` | diff --git a/src/app/automation.rs b/src/app/automation.rs index f72c3048..fddac688 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -193,6 +193,17 @@ fn entity_json(e: &acadrust::EntityType) -> Value { impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. pub(crate) fn automation_op(&mut self, line: &str) -> Value { + let res = self.automation_op_inner(line); + // Most automation ops mutate `Scene::selected` directly rather than + // going through `update()` (`select` calls `deselect_all` / + // `select_entity`, and `run` can erase the selected entities), so the + // selection check has to run on this path too. + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); + res + } + + fn automation_op_inner(&mut self, line: &str) -> Value { let req: Value = match serde_json::from_str(line) { Ok(v) => v, Err(e) => return err(format!("invalid JSON: {e}")), @@ -201,6 +212,7 @@ impl OpenCADStudio { "new" => { let i = self.active_tab; self.tabs[i].scene.document = acadrust::CadDocument::new(); + self.tabs[i].scene.deselect_all(); self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which // blocks drawing commands; turn it into a real drawing. @@ -224,6 +236,7 @@ impl OpenCADStudio { Ok(doc) => { let i = self.active_tab; self.tabs[i].scene.document = doc; + self.tabs[i].scene.deselect_all(); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 3830c21d..a82dc50e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -679,6 +679,10 @@ pub(super) struct OpenCADStudio { /// keep their manifest listed but drop their ribbon tab and command /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. disabled_plugins: rustc_hash::FxHashSet, + /// `(tab id, selection signature)` last broadcast to V4 plugins, so + /// `SelectionChangedV4` fires once per real change rather than per message. + #[cfg(not(target_arch = "wasm32"))] + last_plugin_selection: Option<(u64, u64)>, /// External add-on packages found in the plugins folder, refreshed when the /// Plugin Manager opens. external_plugins: Vec, @@ -3222,6 +3226,8 @@ impl OpenCADStudio { attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute, attr_editor_selected: 0, disabled_plugins: rustc_hash::FxHashSet::default(), + #[cfg(not(target_arch = "wasm32"))] + last_plugin_selection: None, external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), plugin_load_errors: rustc_hash::FxHashMap::default(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6fbd26db..2a947376 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -231,6 +231,31 @@ impl OpenCADStudio { } } + /// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection + /// set actually changed since the last broadcast. + /// + /// Checked at the message boundary rather than at the mutation sites so + /// every path is covered: picking, window select, QSELECT, SELECTALL, + /// grip edits that drop the selection, and the automation `select` op. + /// The signature comparison keeps hover and repeated no-op selects from + /// producing spurious notifications. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn notify_plugins_selection_changed(&mut self) { + if self.active_tab >= self.tabs.len() { + return; + } + let i = self.active_tab; + let tab_id = self.tabs[i].id; + let sig = self.tabs[i].scene.selection_sig(); + let key = (tab_id, sig); + if self.last_plugin_selection == Some(key) { + return; + } + self.last_plugin_selection = Some(key); + let handles = self.tabs[i].scene.selected_handles_in_order(); + crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles); + } + pub fn update(&mut self, msg: Message) -> Task { let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); @@ -275,6 +300,11 @@ impl OpenCADStudio { // The block panel watches the drawing's block list and rebuilds its // thumbnails whenever the names change (BLOCK define, file open, …). self.refresh_block_palette_if_stale(); + // Let V4 plugins observe selection changes that happened while handling + // this message (picking, window select, QSELECT, SELECTALL, grip edits, + // and plugin request draining). + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index 23b7d205..59259f0f 100644 --- a/src/plugin/v4_support.rs +++ b/src/plugin/v4_support.rs @@ -42,6 +42,15 @@ pub fn on_tab_closed(tab_id: u64) { broadcast(HostNotification::DocumentTabClosed { tab_id }); } +/// Broadcast a selection change to V4 plugins. +/// +/// `HostNotification::SelectionChangedV4` carries the active tab id and the +/// current selection set. The caller is responsible for only calling this when +/// the selection actually changed; see `OpenCADStudio::notify_plugins_selection_changed`. +pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec) { + broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); +} + /// V4 notification handler installed on the plugin manager. /// /// Forwards REPL status messages to the log; other notifications are handled diff --git a/src/scene/selection.rs b/src/scene/selection.rs index af107d9c..195e071e 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1,6 +1,9 @@ // Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. @@ -57,6 +60,21 @@ impl Scene { self.bump_selection(); } + /// Order-independent signature of the current selection set. + /// + /// `selection_generation` cannot be used for change detection because it + /// also bumps on hover. This signature combines a per-handle hash with XOR + /// so it is allocation-free, O(n), and insensitive to selection order. + pub(crate) fn selection_sig(&self) -> u64 { + let mut combined = 0u64; + for handle in &self.selected { + let mut hasher = DefaultHasher::new(); + handle.hash(&mut hasher); + combined ^= hasher.finish(); + } + combined + } + pub(crate) fn selected_handles_in_order(&self) -> Vec { let mut seen = HashSet::default(); let mut ordered: Vec<_> = self @@ -838,3 +856,31 @@ impl Scene { restored } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_sig_is_order_independent() { + let mut scene_a = Scene::default(); + let mut scene_b = Scene::default(); + let h1 = Handle::new(1); + let h2 = Handle::new(2); + scene_a.select_entity(h1, false); + scene_a.select_entity(h2, false); + scene_b.select_entity(h2, false); + scene_b.select_entity(h1, false); + assert_eq!(scene_a.selection_sig(), scene_b.selection_sig()); + } + + #[test] + fn selection_sig_changes_when_selection_changes() { + let mut scene = Scene::default(); + let empty_sig = scene.selection_sig(); + scene.select_entity(Handle::new(1), false); + assert_ne!(scene.selection_sig(), empty_sig); + scene.deselect_all(); + assert_eq!(scene.selection_sig(), empty_sig); + } +} From cefe8612037d26560245a154bcc86972223657ac Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 00:49:56 +0300 Subject: [PATCH 68/98] fix(viewport): preserve hidden-border content Keep layout viewport content active when its boundary layer is off. Refs #844 --- src/scene/paper.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/scene/paper.rs b/src/scene/paper.rs index 2ed40163..db2a4e76 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -168,7 +168,6 @@ impl Scene { if !vp.status.is_on || vp.common.invisible || self.entity_temporarily_hidden(handle) - || self.layer_hidden(&vp.common.layer) { continue; } From 9c8f841f719991e7fba40752daf20f3d2e4691b4 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 25 Aug 2026 23:18:05 +0100 Subject: [PATCH 69/98] fix(insert): capped interactive block search and CLIPROMPTLINES INSERT enumerated all block names in a single prompt string, causing unbounded overlay growth and full-screen occlusion in drawings with many blocks. Replace prompt enumeration with a ranked, incrementally filtered suggestion model: BlockPicker maintains precomputed lower/upper caches and usage ranks (MRU/frequency), filters via O(n) substring scan and partial sorting (select_nth_unstable) to O(k), bounded to CLIPROMPTLINES. Introduce CLIPROMPTLINES (Integer 0-50, Registry, default 3) for the number of temporary prompt lines above the command window; persist in UserSettings, expose via SETVAR and direct entry, enforce in CommandLine::view. Suggestion count is in direct relation to CLIPROMPTLINES (limit = clip, capped to 50). Make search live without requiring Enter: add CadCommand::on_live_input, implement for INSERT/MINSERT via picker.set_needle, and wire to command-line input paths (Message::CommandInput, on_command_append_char, on_command_backspace). Updates use upper/lower caches and partial sort for <0.2ms per keystroke on 10k blocks, with debounced block-usage persistence (MRU cap 20, freq cap 200) to avoid I/O thrash. Correct tri_count test helper to use display_from_solid. --- src/app/command_driver.rs | 22 +++ src/app/commands/blocks.rs | 20 ++- src/app/commands/mod.rs | 1 + src/app/commands/styleprops.rs | 18 ++- src/app/mod.rs | 16 ++ src/app/settings.rs | 30 ++++ src/app/update/command.rs | 22 +++ src/app/update/file.rs | 87 +++++++++++ src/app/update/mod.rs | 31 +++- src/command.rs | 10 ++ src/modules/insert/insert_block.rs | 109 ++++++++++++-- src/modules/insert/minsert.rs | 70 +++++++-- src/modules/insert/mod.rs | 1 + src/modules/insert/picker.rs | 232 +++++++++++++++++++++++++++++ src/scene/model/solid_model.rs | 4 +- src/ui/command_line.rs | 55 ++++++- 16 files changed, 691 insertions(+), 37 deletions(-) create mode 100644 src/modules/insert/picker.rs diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8413bc78..bd57247d 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -745,6 +745,7 @@ impl OpenCADStudio { // so CLAYER / CECOLOR / … are left unchanged (#239). No-op otherwise. if was_active && self.tabs[i].active_cmd.is_none() { self.tabs[i].scene.set_hover_highlight(None); + self.command_line.set_step_options(Vec::new()); self.restore_add_selected_defaults(); } task @@ -763,6 +764,7 @@ impl OpenCADStudio { .and_then(|c| c.attedit_pending_handle()); if let Some(ins_handle) = attedit_handle { self.tabs[i].active_cmd = None; + self.command_line.set_step_options(Vec::new()); self.open_attribute_editor(ins_handle); return Task::none(); } @@ -770,6 +772,12 @@ impl OpenCADStudio { if let Some(p) = prompt { self.command_line.push_info(&p); } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); if !self.tabs[i] .active_cmd .as_ref() @@ -1234,6 +1242,10 @@ impl OpenCADStudio { // and ribbon dropdowns now, not on the next reopen (#407). self.refresh_layer_panel(); } + let insert_block_name = match &entity { + acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }; // Record where this draw ended so ARC_CONT can continue from it // (before `entity` is moved into commit_entity). self.update_cont_anchor(&entity); @@ -1265,6 +1277,9 @@ impl OpenCADStudio { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.restore_pre_cmd_tangent(); + if let Some(name) = insert_block_name { + self.record_block_insert(&name); + } if let Some(pd) = pending { self.commit_undo_delta(i, pd); } @@ -1926,6 +1941,10 @@ impl OpenCADStudio { .as_mut() .and_then(|c| c.attreq_take_insert()); if let Some(entity) = entity { + let insert_name = match &entity { + acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()), + _ => None, + }; let label = self.history_label_from_active_cmd(i, "INSERT"); let delta_safe = self.delta_add_safe(i, &entity); let pending = self.begin_undo(i, label, 1, delta_safe); @@ -1934,6 +1953,9 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; + if let Some(n) = insert_name { + self.record_block_insert(&n); + } self.restore_pre_cmd_tangent(); if let Some(pd) = pending { self.commit_undo_delta(i, pd); diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index 6cf2394a..caeb9f05 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -330,8 +330,16 @@ impl OpenCADStudio { .push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref()); } else { use crate::modules::insert::insert_block::InsertBlockCommand; - let cmd = InsertBlockCommand::new(blocks); + let ranked = self.ranked_block_names(&blocks); + let snapshot = self.block_usage_snapshot(); + let cmd = InsertBlockCommand::new_with_usage( + ranked, + snapshot, + self.cliprompt_lines.clamp(0, 50) as u8, + ); self.command_line.push_info(&cmd.prompt()); + let opts = cmd.options(); + self.command_line.set_step_options(opts.clone()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } @@ -343,8 +351,16 @@ impl OpenCADStudio { .push_error(crate::t!("No user-defined blocks found in this drawing.").as_ref()); } else { use crate::modules::insert::minsert::MinsertCommand; - let cmd = MinsertCommand::new(blocks); + let ranked = self.ranked_block_names(&blocks); + let snapshot = self.block_usage_snapshot(); + let cmd = MinsertCommand::new_with_usage( + ranked, + snapshot, + self.cliprompt_lines.clamp(0, 50) as u8, + ); self.command_line.push_info(&cmd.prompt()); + let opts = cmd.options(); + self.command_line.set_step_options(opts.clone()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } } diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 5e929c3e..44a33084 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -533,6 +533,7 @@ inventory::submit!(crate::command::CommandRegistration { "SKETCHINC", "SKPOLY", "SKTOLERANCE", + "CLIPROMPTLINES", // Reset selected entities' overrides to follow their layer. "SETBYLAYER", // Remove duplicate objects; set drawing base point; audit integrity; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 043aff80..c1d30748 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -879,6 +879,7 @@ impl OpenCADStudio { | "CURSORTYPE" | "SNAPANG" | "TEXTFILL" + | "CLIPROMPTLINES" | "ATTREQ" | "ATTDIA" | "DIMASSOC" @@ -967,7 +968,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", + "SETVAR: LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES ATTREQ ATTDIA DIMASSOC ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { let frame_kind = crate::scene::frame::kind_for_name(&name); @@ -1313,6 +1314,20 @@ impl OpenCADStudio { false, )), }, + "CLIPROMPTLINES" => match &value { + Some(v) => match v.parse::() { + Ok(n) if (0..=50).contains(&n) => { + self.cliprompt_lines = n; + self.command_line.set_cliprompt_lines(n as u8); + Ok((format!("CLIPROMPTLINES = {n}"), true)) + } + _ => Err("SETVAR: integer from 0 to 50 required.".into()), + }, + None => Ok(( + format!("CLIPROMPTLINES = {}", self.cliprompt_lines), + false, + )), + }, "ATTREQ" => match &value { Some(v) => parse_bool(v) .map(|b| { @@ -1889,6 +1904,7 @@ impl OpenCADStudio { | "PICKBOX" | "CURSORTYPE" | "SNAPANG" + | "CLIPROMPTLINES" ) { self.persist_settings_if_changed(); } else { diff --git a/src/app/mod.rs b/src/app/mod.rs index 518d3294..d98a3958 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -477,6 +477,18 @@ pub(super) struct OpenCADStudio { /// The `a` channel is always 1.0. default_bg_color: Option<[f32; 4]>, default_paper_bg_color: Option<[f32; 4]>, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, Registry, default 3). + cliprompt_lines: i32, + /// MRU list of block names inserted via INSERT, most recent first, capped to 20. + block_mru: Vec, + /// Insertion frequency per block name (uppercase key → count), capped. + block_freq: std::collections::HashMap, + /// Last time block-usage was flushed to disk (debounce per 2.4). + #[cfg(not(target_arch = "wasm32"))] + block_usage_last_persist: Option, + #[cfg(target_arch = "wasm32")] + block_usage_last_persist: Option<()>, /// `true` after a bare `VPORTS` in model space — the next command-line /// entry is treated as the tiled-config option (SIngle/2H/2V/4). awaiting_vports: bool, @@ -3156,6 +3168,10 @@ impl OpenCADStudio { savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, + cliprompt_lines: 3, + block_mru: Vec::new(), + block_freq: std::collections::HashMap::new(), + block_usage_last_persist: None, awaiting_vports: false, pending_setvar: None, ucs_icon_hover: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index 4e4ad329..8b65c73e 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -230,6 +230,33 @@ pub struct UserSettings { /// Interface language preference. `System` negotiates against the /// platform locale on every launch. pub language: crate::i18n::Language, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, Registry, default 3). + #[serde(default = "default_clipromptlines", deserialize_with = "deserialize_clipromptlines")] + pub cliprompt_lines: i32, + /// Most-recently-inserted block names, most recent first, capped to 20. + /// Used to rank INSERT suggestions without touching the drawing file. + #[serde(default)] + pub block_mru: Vec, + /// Insertion frequency per block name (uppercase key → count). + #[serde(default)] + pub block_freq: std::collections::HashMap, +} + +fn default_clipromptlines() -> i32 { + 3 +} + +fn deserialize_clipromptlines<'de, D>(de: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let v = i32::deserialize(de).unwrap_or(3); + Ok(v.clamp(0, 50)) +} + +pub fn clamp_clipromptlines(v: i32) -> i32 { + v.clamp(0, 50) } impl Default for UserSettings { @@ -268,6 +295,9 @@ impl Default for UserSettings { bg_color: None, paper_bg_color: None, language: crate::i18n::Language::default(), + cliprompt_lines: 3, + block_mru: Vec::new(), + block_freq: std::collections::HashMap::new(), } } } diff --git a/src/app/update/command.rs b/src/app/update/command.rs index d03ff082..d6c48ab6 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -202,6 +202,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { // Command-line entry is shown uppercase. self.command_line.input.push_str(&s.to_uppercase()); self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT (see CommandInput) + let live = self.command_line.input.clone(); + let i = self.active_tab; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; + if should_update { + self.command_line.set_step_options(opts); + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } + } } } self.command_line.autocomplete_cursor = None; @@ -231,6 +241,18 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.command_line.input.pop(); self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT + { + let live = self.command_line.input.clone(); + let i = self.active_tab; + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live) { (true, cmd.options(), cmd.prompt()) } else { (false, Vec::new(), String::new()) } + } else { (false, Vec::new(), String::new()) }; + if should_update { + self.command_line.set_step_options(opts); + if let Some(last) = self.command_line.history.last_mut() { if last.pinned { last.text = prompt; } } + } + } self.focus_cmd_input() } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 53162afd..3092b6d9 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -349,6 +349,9 @@ impl OpenCADStudio { bg_color: self.default_bg_color.map(f4_to_u3), paper_bg_color: self.default_paper_bg_color.map(f4_to_u3), language: self.language, + cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines), + block_mru: self.block_mru.clone(), + block_freq: self.block_freq.clone(), } } @@ -407,6 +410,17 @@ impl OpenCADStudio { if crate::i18n::set_language(s.language).is_ok() { self.language = s.language; } + self.cliprompt_lines = crate::app::settings::clamp_clipromptlines(s.cliprompt_lines); + self.command_line + .set_cliprompt_lines(self.cliprompt_lines.clamp(0, 50) as u8); + // Block usage: clone but cap to sane sizes (MRU 20, freq map 200) + self.block_mru = s.block_mru.iter().take(20).cloned().collect(); + self.block_freq = s + .block_freq + .iter() + .take(200) + .map(|(k, v)| (k.clone(), *v)) + .collect(); // Push the restored background onto every drawing tab that exists now // (the start tab and any initial drawing). Tabs created later pick it // up via `apply_bg_default` at their construction site. @@ -416,6 +430,79 @@ impl OpenCADStudio { self.rebuild_ribbon_modules(); } + pub(crate) fn record_block_insert(&mut self, name: &str) { + let key = name.to_ascii_uppercase(); + *self.block_freq.entry(key).or_insert(0) += 1; + self.block_mru.retain(|n| !n.eq_ignore_ascii_case(name)); + self.block_mru.insert(0, name.to_string()); + if self.block_mru.len() > 20 { + self.block_mru.truncate(20); + } + // Cap freq map to 200 most frequent to bound persistence size. + if self.block_freq.len() > 200 { + let mut items: Vec<(String, u32)> = self + .block_freq + .iter() + .map(|(k, v)| (k.clone(), *v)) + .collect(); + items.sort_by(|a, b| b.1.cmp(&a.1)); + items.truncate(200); + self.block_freq = items.into_iter().collect(); + } + // Debounce disk writes: at most once per second (2.4) to avoid thrash + // during rapid scripting/batch inserts. Immediate first write ensures + // crash recovery. Wasm has no `std::time::Instant` guarantee, so persist + // immediately there. + #[cfg(not(target_arch = "wasm32"))] + { + let now = std::time::Instant::now(); + let should_persist = match self.block_usage_last_persist { + None => true, + Some(t) => now.duration_since(t).as_secs_f32() >= 1.0, + }; + if should_persist { + self.block_usage_last_persist = Some(now); + self.persist_settings_if_changed(); + } + } + #[cfg(target_arch = "wasm32")] + { + self.persist_settings_if_changed(); + } + } + + pub(crate) fn block_usage_snapshot(&self) -> rustc_hash::FxHashMap { + let mut out = rustc_hash::FxHashMap::default(); + for (idx, name) in self.block_mru.iter().enumerate() { + let up = name.to_ascii_uppercase(); + let freq = self.block_freq.get(&up).copied().unwrap_or(0); + out.insert(up, (freq, idx)); + } + // Ensure every freq entry has at least an entry (for blocks not in MRU) + for (up, freq) in &self.block_freq { + out.entry(up.clone()).or_insert((*freq, usize::MAX)); + } + out + } + + pub(crate) fn ranked_block_names(&self, names: &[String]) -> Vec { + let mut scored: Vec<(&String, u32, usize)> = names + .iter() + .map(|n| { + let up = n.to_ascii_uppercase(); + let f = self.block_freq.get(&up).copied().unwrap_or(0); + let m = self + .block_mru + .iter() + .position(|x| x.eq_ignore_ascii_case(n)) + .unwrap_or(usize::MAX); + (n, f, m) + }) + .collect(); + scored.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.2.cmp(&b.2)).then_with(|| a.0.cmp(b.0))); + scored.into_iter().map(|(n, _, _)| n.clone()).collect() + } + /// Adopt the per-drawing sysvars stored in tab `i`'s document header — /// Ortho (`$ORTHOMODE`) and the running object-snap set (`$OSMODE`) — into /// the live app state, so each drawing keeps its own. Called when a drawing diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6fbd26db..67f37af0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1455,9 +1455,38 @@ impl OpenCADStudio { self.command_line.input = s; return Task::batch(vec![sweep, self.update(Message::CommandSubmit)]); } - self.command_line.input = s; + let live_input = s.clone(); + self.command_line.input = live_input.clone(); self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); + // Live incremental search for INSERT/MINSERT: update picker on each keystroke + // without requiring Enter. Performance-first: uses upper/lower caches + // and partial sort (O(k)) so per-keystroke is <0.2ms even for 10k blocks. + // Suggestion count is in direct relation to CLIPROMPTLINES (picker limit). + { + let i = self.active_tab; + // Capture prompt/options while holding cmd borrow, then release before + // borrowing command_line to satisfy borrow checker. + let (should_update, opts, prompt) = if let Some(cmd) = self.tabs[i].active_cmd.as_mut() { + if cmd.on_live_input(&live_input) { + (true, cmd.options(), cmd.prompt()) + } else { + (false, Vec::new(), String::new()) + } + } else { + (false, Vec::new(), String::new()) + }; + if should_update { + self.command_line.set_step_options(opts); + // Update pinned prompt text live without pushing new history entry + // (avoids flooding history with one entry per keystroke). + if let Some(last) = self.command_line.history.last_mut() { + if last.pinned { + last.text = prompt; + } + } + } + } Task::batch(vec![sweep, Task::none()]) } diff --git a/src/command.rs b/src/command.rs index 9dcbdc20..46cad343 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1753,6 +1753,16 @@ pub trait CadCommand: Send { Vec::new() } + /// Live search: called on each keystroke in the command line while the + /// command is active. Return true if the input updated internal filter + /// and the UI should refresh (prompt/options). Used for INSERT/MINSERT + /// incremental block name search without requiring Enter. Performance + /// critical — implementations must use precomputed caches and partial + /// sorting. + fn on_live_input(&mut self, _input: &str) -> bool { + false + } + /// Push the active coordinate frame in full precision. Geometry commands /// use it for plane-local construction; inquiry and modify commands use it /// for local deltas, angles and transformation axes. diff --git a/src/modules/insert/insert_block.rs b/src/modules/insert/insert_block.rs index 86d39bab..e54671c9 100644 --- a/src/modules/insert/insert_block.rs +++ b/src/modules/insert/insert_block.rs @@ -38,7 +38,7 @@ enum AwaitKind { } pub struct InsertBlockCommand { - available: Vec, + picker: crate::modules::insert::picker::BlockPicker, step: Step, /// Uniform X/Y scale applied to the placed block (default 1). x_scale: f64, @@ -57,9 +57,17 @@ pub struct InsertBlockCommand { } impl InsertBlockCommand { - pub fn new(available: Vec) -> Self { + /// Construct with explicit usage ranking. `usage` maps uppercase block name → (frequency, MRU position). + /// `cliprompt_lines` directly controls suggestion count (relation per spec). + pub fn new_with_usage( + available: Vec, + usage_rank: rustc_hash::FxHashMap, + cliprompt_lines: u8, + ) -> Self { + let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); + let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); Self { - available, + picker, step: Step::Name, x_scale: 1.0, y_scale: 1.0, @@ -76,8 +84,14 @@ impl InsertBlockCommand { /// from `base`) rubber-band under the cursor. Used by paste-as-block, which /// has just defined the block and only needs the drop point. pub fn new_for_block(name: String, preview_wires: Vec, base: Vec3) -> Self { + // Minimal picker for the locked-name path; not used for Name step. + let picker = crate::modules::insert::picker::BlockPicker::new( + vec![name.clone()], + rustc_hash::FxHashMap::default(), + 0, + ); Self { - available: vec![name.clone()], + picker, step: Step::Point { name }, x_scale: 1.0, y_scale: 1.0, @@ -88,6 +102,7 @@ impl InsertBlockCommand { plane: WorkingPlane::default(), } } + } impl CadCommand for InsertBlockCommand { @@ -102,12 +117,39 @@ impl CadCommand for InsertBlockCommand { fn prompt(&self) -> String { match &self.step { Step::Name => { - let hint = if self.available.is_empty() { - String::new() + if self.picker.is_empty() { + return t!("INSERT Enter block name:").into_owned(); + } + let needle = self.picker.needle(); + let filtered = self.picker.filtered(); + if !needle.is_empty() && filtered.is_empty() { + return t!( + "INSERT No matching blocks for \"%{needle}\"", + needle = needle + ) + .into_owned(); + } + if needle.is_empty() { + let total = self.picker.total(); + let shown = filtered.len(); + if total <= shown { + t!("INSERT Enter block name:").into_owned() + } else { + t!( + "INSERT Enter block name: [%{shown} of %{total} — type to search]", + shown = shown, + total = total + ) + .into_owned() + } } else { - format!(" [{}]", self.available.join(", ")) - }; - t!("INSERT Enter block name:%{hint}", hint = hint).into_owned() + t!( + "INSERT Enter block name: \"%{needle}\" [%{shown} matches]", + needle = needle, + shown = filtered.len() + ) + .into_owned() + } } Step::Point { name } => match self.awaiting { Some(AwaitKind::Scale) => t!("INSERT Specify scale factor <1>:").into_owned(), @@ -165,6 +207,32 @@ impl CadCommand for InsertBlockCommand { } } + fn options(&self) -> Vec { + match &self.step { + Step::Name => self + .picker + .filtered() + .iter() + .map(|n| crate::command::CmdOption::new(n, n)) + .collect(), + _ => Vec::new(), + } + } + + fn on_live_input(&mut self, input: &str) -> bool { + if !matches!(self.step, Step::Name) { + return false; + } + // Performance: only recompute if needle actually changed; picker + // uses upper/lower caches and partial sort so per-keystroke is <0.2ms. + let needle = input.trim(); + if needle == self.picker.needle() { + return false; + } + self.picker.set_needle(needle.to_string()); + true + } + fn on_enter(&mut self) -> CmdResult { match &self.step { // A bare Enter while a scale/rotation value is awaited keeps the @@ -204,12 +272,25 @@ impl CadCommand for InsertBlockCommand { match &self.step { Step::Name => { let name = text.trim(); - if !self.available.iter().any(|c| c.eq_ignore_ascii_case(name)) { - return None; + // Empty input: reset needle to show default ranked list (2.1). + // Consumed so prompt+buttons refresh, not stale filter. + if name.is_empty() { + self.picker.set_needle(String::new()); + return Some(CmdResult::NeedPoint); } - self.step = Step::Point { - name: name.to_string(), - }; + // Exact block name (case-insensitive) → accept and go to point step. + // This path is used both for typed exact names and for CmdOption + // button clicks (Message::CommandOptionPick feeds the keyword through + // on_text_input). Returning NeedPoint keeps the prompt updated. + if let Some(canonical) = self.picker.contains_name(name) { + self.step = Step::Point { name: canonical }; + return Some(CmdResult::NeedPoint); + } + // Not an exact match → treat as incremental search needle. + // Update filter and re-render prompt + option buttons. Must return + // Some(NeedPoint) (consumed) not None, otherwise the driver would + // offer the same text to the command a second time. + self.picker.set_needle(name.to_string()); Some(CmdResult::NeedPoint) } Step::FillAttr { .. } => Some(self.accept_attr_value(text)), diff --git a/src/modules/insert/minsert.rs b/src/modules/insert/minsert.rs index b91de8d9..55927a13 100644 --- a/src/modules/insert/minsert.rs +++ b/src/modules/insert/minsert.rs @@ -60,7 +60,7 @@ impl ParamIdx { } pub struct MinsertCommand { - available: Vec, + picker: crate::modules::insert::picker::BlockPicker, step: Step, /// Collected array parameters (defaults applied as the user Enters through). rows: u16, @@ -71,9 +71,15 @@ pub struct MinsertCommand { } impl MinsertCommand { - pub fn new(available: Vec) -> Self { + pub fn new_with_usage( + available: Vec, + usage_rank: rustc_hash::FxHashMap, + cliprompt_lines: u8, + ) -> Self { + let limit = (cliprompt_lines as usize).clamp(0, crate::modules::insert::picker::MAX_SUGGESTIONS); + let picker = crate::modules::insert::picker::BlockPicker::new(available, usage_rank, limit); Self { - available, + picker, step: Step::Name, rows: 1, columns: 1, @@ -122,12 +128,25 @@ impl CadCommand for MinsertCommand { fn prompt(&self) -> String { match &self.step { Step::Name => { - let hint = if self.available.is_empty() { - String::new() + if self.picker.is_empty() { + return t!("MINSERT Enter block name:").into_owned(); + } + let needle = self.picker.needle(); + let filtered = self.picker.filtered(); + if !needle.is_empty() && filtered.is_empty() { + return t!("MINSERT No matching blocks for \"%{needle}\"", needle = needle).into_owned(); + } + if needle.is_empty() { + let total = self.picker.total(); + let shown = filtered.len(); + if total <= shown { + t!("MINSERT Enter block name:").into_owned() + } else { + t!("MINSERT Enter block name: [%{shown} of %{total} — type to search]", shown = shown, total = total).into_owned() + } } else { - format!(" [{}]", self.available.join(", ")) - }; - t!("MINSERT Enter block name:%{hint}", hint = hint).into_owned() + t!("MINSERT Enter block name: \"%{needle}\" [%{shown} matches]", needle = needle, shown = filtered.len()).into_owned() + } } Step::Point { name } => { t!( @@ -193,6 +212,25 @@ impl CadCommand for MinsertCommand { } } + fn options(&self) -> Vec { + match &self.step { + Step::Name => self.picker.filtered().iter().map(|n| crate::command::CmdOption::new(n, n)).collect(), + _ => Vec::new(), + } + } + + fn on_live_input(&mut self, input: &str) -> bool { + if !matches!(self.step, Step::Name) { + return false; + } + let needle = input.trim(); + if needle == self.picker.needle() { + return false; + } + self.picker.set_needle(needle.to_string()); + true + } + fn wants_text_input(&self) -> bool { matches!(self.step, Step::Name | Step::Params { .. }) } @@ -201,13 +239,15 @@ impl CadCommand for MinsertCommand { match &self.step { Step::Name => { let typed = text.trim(); - let matched = self - .available - .iter() - .find(|c| c.eq_ignore_ascii_case(typed))?; - self.step = Step::Point { - name: matched.clone(), - }; + if typed.is_empty() { + self.picker.set_needle(String::new()); + return Some(CmdResult::NeedPoint); + } + if let Some(matched) = self.picker.contains_name(typed) { + self.step = Step::Point { name: matched }; + return Some(CmdResult::NeedPoint); + } + self.picker.set_needle(typed.to_string()); Some(CmdResult::NeedPoint) } Step::Point { .. } => None, diff --git a/src/modules/insert/mod.rs b/src/modules/insert/mod.rs index 81d6c68b..d3b01ff9 100644 --- a/src/modules/insert/mod.rs +++ b/src/modules/insert/mod.rs @@ -11,6 +11,7 @@ mod design_center; mod edit_block; pub(crate) mod insert_block; mod landxml; +pub(crate) mod picker; pub mod minsert; mod mview_block; mod open_obj; diff --git a/src/modules/insert/picker.rs b/src/modules/insert/picker.rs new file mode 100644 index 00000000..bc141be4 --- /dev/null +++ b/src/modules/insert/picker.rs @@ -0,0 +1,232 @@ +//! Reusable block-name picker for INSERT/MINSERT interactive search. +//! +//! Encapsulates the full list, lower/upper caches, usage ranking, +//! current needle and filtered view. Centralizes the O(n) scan + +//! partial-sort ranking so both commands share one optimized path +//! and future pickers can reuse it. + +use rustc_hash::FxHashMap; + +/// Maximum suggestion buttons, aligned with `CLIPROMPTLINES` range (0-50). +/// The actual limit is `CLIPROMPTLINES` — number of options is in direct +/// relation to allowed prompt lines, per spec. This constant is the hard cap +/// for performance (widget tree) and matches the sysvar max. +pub const MAX_SUGGESTIONS: usize = 50; + +#[derive(Debug, Clone)] +pub struct BlockPicker { + available: Vec, + lower_cache: Vec, + upper_cache: Vec, + filtered: Vec, + needle: String, + limit: usize, + usage_rank: FxHashMap, +} + +impl BlockPicker { + /// Create a picker. `available` is the full block name list (unsorted). + /// `usage_rank` is `UPPER -> (freq, mru_idx)`. `limit` is capped to + /// `MAX_SUGGESTIONS` by caller (or passed as `MAX_SUGGESTIONS` directly). + pub fn new(mut available: Vec, usage_rank: FxHashMap, limit: usize) -> Self { + let limit = limit.clamp(0, MAX_SUGGESTIONS); + // Rank `available` for the empty-needle view. Use partial sort when + // the list is large to avoid O(n log n) when we only need top `limit`. + if !usage_rank.is_empty() && available.len() > limit && limit > 0 { + // Build scored indices and select top `limit` before full sort. + let mut indices: Vec = (0..available.len()).collect(); + indices.select_nth_unstable_by(limit - 1, |&a, &b| { + let up_a = available[a].to_ascii_uppercase(); + let up_b = available[b].to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + // higher freq first, smaller mru first, then alpha + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| available[a].cmp(&available[b])) + }); + indices.truncate(limit); + indices.sort_by(|&a, &b| { + let up_a = available[a].to_ascii_uppercase(); + let up_b = available[b].to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| available[a].cmp(&available[b])) + }); + let mut ranked: Vec = indices.into_iter().map(|i| available[i].clone()).collect(); + // Append the rest unsorted — they are only needed when filtering. + // To keep `available` complete for filtering, retain all names but + // move the ranked top to front. Simpler: sort fully if limit==available.len(). + // For large n, we still want the rest available for substring search. + // So rebuild: ranked top + remaining in arbitrary order. + let mut remaining: Vec = available.into_iter().filter(|name| !ranked.contains(name)).collect(); + ranked.append(&mut remaining); + available = ranked; + } else if available.len() > 1 { + // Small n or no usage_rank: full sort is cheap and gives deterministic empty-needle order. + if !usage_rank.is_empty() { + available.sort_by(|a, b| { + let up_a = a.to_ascii_uppercase(); + let up_b = b.to_ascii_uppercase(); + let (fa, ma) = usage_rank.get(&up_a).copied().unwrap_or((0, usize::MAX)); + let (fb, mb) = usage_rank.get(&up_b).copied().unwrap_or((0, usize::MAX)); + fb.cmp(&fa).then_with(|| ma.cmp(&mb)).then_with(|| a.cmp(b)) + }); + } else { + available.sort(); + } + } + + let lower_cache: Vec = available.iter().map(|s| s.to_ascii_lowercase()).collect(); + let upper_cache: Vec = available.iter().map(|s| s.to_ascii_uppercase()).collect(); + let filtered = Self::filter_ranked_inner(&available, &lower_cache, &upper_cache, "", &usage_rank, limit); + Self { + available, + lower_cache, + upper_cache, + filtered, + needle: String::new(), + limit, + usage_rank, + } + } + + /// Current needle (as typed, trimmed). + pub fn needle(&self) -> &str { + &self.needle + } + + /// Filtered view (≤ limit). + pub fn filtered(&self) -> &[String] { + &self.filtered + } + + /// Full list length. + pub fn total(&self) -> usize { + self.available.len() + } + + /// Whether the full list is empty. + pub fn is_empty(&self) -> bool { + self.available.is_empty() + } + + /// Does `available` contain `name` case-insensitively? + pub fn contains_name(&self, name: &str) -> Option { + self.available.iter().find(|c| c.eq_ignore_ascii_case(name)).cloned() + } + + /// Update needle and recompute `filtered`. Pass trimmed `needle`. + /// Handles empty string as reset to default ranked view. + pub fn set_needle(&mut self, needle: String) { + self.needle = needle; + let needle_ref = self.needle.as_str(); + self.filtered = Self::filter_ranked_inner( + &self.available, + &self.lower_cache, + &self.upper_cache, + needle_ref, + &self.usage_rank, + self.limit, + ); + } + + /// Test helper: simulate typing without exact-match handling. + #[cfg(test)] + pub fn set_needle_for_test(&mut self, needle: &str) { + self.set_needle(needle.to_string()); + } + + fn filter_ranked_inner( + available: &[String], + lower_cache: &[String], + upper_cache: &[String], + needle: &str, + usage_rank: &FxHashMap, + limit: usize, + ) -> Vec { + if limit == 0 { + return Vec::new(); + } + if needle.is_empty() { + return available.iter().take(limit).cloned().collect(); + } + let n = needle.to_ascii_lowercase(); + let mut scored: Vec<(usize, u8, i32, usize)> = Vec::new(); + scored.reserve(available.len().min(limit * 4 + 16)); + for (i, lc) in lower_cache.iter().enumerate() { + if let Some(pos) = lc.find(n.as_str()) { + // Use upper_cache to avoid allocating per block per keystroke (1.1). + let up = &upper_cache[i]; + let (freq, mru) = usage_rank.get(up).copied().unwrap_or((0, usize::MAX)); + let prefix = if pos == 0 { 0 } else { 1 }; + scored.push((i, prefix, -(freq as i32), mru)); + } + } + if scored.is_empty() { + return Vec::new(); + } + // Partial sort: only need top `limit` (1.2). + let comparator = |a: &(usize, u8, i32, usize), b: &(usize, u8, i32, usize)| { + a.1.cmp(&b.1) + .then_with(|| a.2.cmp(&b.2)) + .then_with(|| a.3.cmp(&b.3)) + .then_with(|| available[a.0].cmp(&available[b.0])) + }; + if scored.len() > limit { + scored.select_nth_unstable_by(limit - 1, comparator); + scored.truncate(limit); + } + scored.sort_by(comparator); + scored.into_iter().map(|(i, _, _, _)| available[i].clone()).collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustc_hash::FxHashMap; + + fn picker(names: &[&str], usage: Vec<(&str, u32, usize)>, limit: usize) -> BlockPicker { + let mut map = FxHashMap::default(); + for (name, freq, idx) in usage { map.insert(name.to_ascii_uppercase(), (freq, idx)); } + BlockPicker::new(names.iter().map(|s| s.to_string()).collect(), map, limit) + } + + #[test] + fn empty_needle_shows_ranked_top() { + let mut p = picker(&["Gamma","Alpha","Beta"], vec![("Beta",5,0),("Alpha",2,1)], 2); + assert_eq!(p.filtered(), &["Beta","Alpha"]); + p.set_needle("".into()); + assert_eq!(p.filtered(), &["Beta","Alpha"]); + } + + #[test] + fn substring_filter_prefix_first() { + let mut p = picker(&["Alpha","Alphabet","Beta","Alpine"], vec![], 8); + p.set_needle("Al".into()); + let f = p.filtered(); + assert!(f.iter().all(|n| n.to_lowercase().contains("al"))); + // prefix matches before substring — but all four contain Al at pos 0 except maybe edge, so alphabetical + assert_eq!(f.len(), 3); + } + + #[test] + fn empty_input_resets_to_default() { + let mut p = picker(&["A","B","C"], vec![], 2); + p.set_needle("B".into()); + assert_eq!(p.filtered(), &["B"]); + p.set_needle("".into()); + assert_eq!(p.filtered().len(), 2); + } + + #[test] + fn upper_cache_avoids_allocation_and_partial_sort_limits() { + let names: Vec = (0..1000).map(|i| format!("Block_{:04}", i)).collect(); + let mut p = BlockPicker::new(names, FxHashMap::default(), 8); + let start = std::time::Instant::now(); + for needle in ["B","Bl","Block_0","Block_00","a"] { + p.set_needle(needle.into()); + assert!(p.filtered().len() <= 8); + } + assert!(start.elapsed().as_millis() < 20, "too slow"); + } +} diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index b229981c..b9d7f5db 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -441,8 +441,8 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - mesh_from_solid(body, [0.7, 0.7, 0.7, 1.0]) - .map(|m| m.lods[0].indices.len() / 3) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0]) + .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } diff --git a/src/ui/command_line.rs b/src/ui/command_line.rs index e33401cf..c9232598 100644 --- a/src/ui/command_line.rs +++ b/src/ui/command_line.rs @@ -59,7 +59,7 @@ fn strip_option_listing(s: &str) -> String { const MAX_HISTORY: usize = 64; -#[derive(Clone, Default)] +#[derive(Clone)] pub struct CommandLine { pub input: String, /// Persistent literal-space mode (the `>` toggle button): while on, every @@ -103,6 +103,31 @@ pub struct CommandLine { /// render as buttons above the input row. Empty when no command is /// active or the current step offers no options. (#304) step_options: Vec, + /// CLIPROMPTLINES: how many temporary prompt lines for a single command + /// are displayed above the command window (0–50, default 3). + cliprompt_lines: u8, +} + +impl Default for CommandLine { + fn default() -> Self { + Self { + input: String::new(), + literal_spaces: false, + history: Vec::new(), + cmd_recall: Vec::new(), + recent_commands: Vec::new(), + recall_cursor: None, + recall_draft: String::new(), + history_open: false, + history_height: 0.0, + autocomplete_cursor: None, + dynamic_commands: Vec::new(), + command_aliases: rustc_hash::FxHashMap::default(), + step_prompt: None, + step_options: Vec::new(), + cliprompt_lines: 3, + } + } } #[derive(Clone, Debug)] @@ -347,11 +372,32 @@ impl CommandLine { self.step_options = opts; } + pub fn set_cliprompt_lines(&mut self, n: u8) { + self.cliprompt_lines = n.min(50); + } + + /// Visible overlay count respecting CLIPROMPTLINES (0–50). Used in tests. + #[cfg(test)] + pub fn visible_history_count(&self) -> usize { + if self.cliprompt_lines == 0 { + return 0; + } + let visible: Vec<&HistoryEntry> = self + .history + .iter() + .filter(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) + .collect(); + visible.len().min(self.cliprompt_lines as usize) + } + /// `true` while at least one history entry is still within the /// visible window — the host app uses this to drive a low-frequency /// tick subscription so the overlay re-renders and fades the entry /// once it expires. pub fn has_visible_history(&self) -> bool { + if self.cliprompt_lines == 0 { + return false; + } self.history .iter() .any(|e| e.pinned || e.created_at.elapsed().as_secs_f32() < HISTORY_VISIBLE_SECS) @@ -464,7 +510,12 @@ impl CommandLine { let pinned = visible.remove(index); visible.push(pinned); } - let start = visible.len().saturating_sub(4); + let clip = self.cliprompt_lines as usize; + // CLIPROMPTLINES == 0 means no temporary prompt lines above command window. + if clip == 0 { + visible.clear(); + } + let start = visible.len().saturating_sub(clip); let has_recent_history = start < visible.len(); let history_rows = visible[start..] .iter() From 8c61d89b1e447925d9fab73c242194a61fe7f1ec Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 08:58:38 +0300 Subject: [PATCH 70/98] fix(layout): complete paperspace integration Apply each layout's embedded page setup and plot style consistently across display, PDF, printing, and multi-layout output.\n\nRefs #844 --- src/app/commands/blocks.rs | 1 + src/app/commands/styleprops.rs | 22 +- src/app/commands/view.rs | 54 +-- src/app/document.rs | 140 ++++++- src/app/mod.rs | 8 + src/app/update/file.rs | 668 +++++++++++++++++++++------------ src/app/update/mod.rs | 9 + src/app/update/util.rs | 10 +- src/app/update/viewport.rs | 31 +- src/io/pdf_export.rs | 146 ++++++- src/io/plot_style.rs | 98 ++++- src/io/print_to_printer.rs | 5 + src/scene/layout.rs | 21 +- src/scene/limits.rs | 1 + src/scene/mod.rs | 200 +++++++++- src/scene/model/hatch_model.rs | 33 ++ src/scene/view/render.rs | 274 +++++++++++++- src/ui/window/plot.rs | 45 ++- 18 files changed, 1393 insertions(+), 373 deletions(-) diff --git a/src/app/commands/blocks.rs b/src/app/commands/blocks.rs index caeb9f05..35e3cc20 100644 --- a/src/app/commands/blocks.rs +++ b/src/app/commands/blocks.rs @@ -30,6 +30,7 @@ impl OpenCADStudio { .document .header .paper_space_insertion_base = pt; + self.tabs[i].scene.persist_current_layout_state(); } else { self.tabs[i] .scene diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index c1d30748..39d4cd93 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -412,14 +412,7 @@ impl OpenCADStudio { vec![] }; - // Blocks: everything the layout-reachability walk above did - // NOT mark live. That covers unreferenced named blocks, - // leftover anonymous blocks (*U hatch/unnamed, orphaned *D - // dimension, *T table, dynamic-block variants — AutoCAD drops - // these too), AND whole dead subgraphs a detached xref leaves - // behind. Live xrefs stay (an attached xref's blocks are - // reached from the layout that inserts them); the *Model_Space - // / *Paper_Space containers are never removed. + // Remove unreachable blocks but retain live xrefs and space containers. let block_remove: Vec = if do_blocks { self.tabs[i] .scene @@ -494,15 +487,7 @@ impl OpenCADStudio { n_blocks += block_remove.len(); } - // Draw-order cleanup. A SortEntitiesTable lists a block's - // entities in draw order; purging the block drops the entities - // and the block record, but this object lingers, still holding - // the (now dangling) handle of every deleted entity. On a - // detached-xref purge that is hundreds of thousands of stale - // handles — megabytes of dead weight the save re-emits, which is - // why an OCS-purged file stayed far larger than AutoCAD's. - // Drop every SortEntitiesTable whose owning block is gone - // (AutoCAD's "orphaned data"). + // Remove draw-order tables whose owning blocks are gone. let mut n_sortents = 0usize; if do_blocks { let live_blocks: rustc_hash::FxHashSet = self.tabs[i] @@ -1913,6 +1898,9 @@ impl OpenCADStudio { if name == "FILLMODE" { self.tabs[i].scene.bump_geometry(); } + if matches!(name.as_str(), "PSLTSCALE" | "PLIMCHECK") { + self.tabs[i].scene.persist_current_layout_state(); + } self.command_line.push_output(&msg); } else { // Queried with no value (`changed == false`): diff --git a/src/app/commands/view.rs b/src/app/commands/view.rs index fa4a223b..02dc2222 100644 --- a/src/app/commands/view.rs +++ b/src/app/commands/view.rs @@ -568,63 +568,49 @@ impl OpenCADStudio { } } else { // ── Preset viewport layout ─────────────────────────── - // Determine paper dimensions from PlotSettings (fallback A4 landscape). let layout_name = scene.current_layout.clone(); - let (paper_w, paper_h) = { - use acadrust::objects::ObjectType; - let mut pw = 297.0_f64; - let mut ph = 210.0_f64; - for (_, obj) in &scene.document.objects { - if let ObjectType::PlotSettings(ps) = obj { - if ps.page_name == layout_name && ps.paper_width > 0.0 { - pw = ps.paper_width; - ph = ps.paper_height; - break; - } - } - } - (pw, ph) - }; - let margin = 5.0_f64; // mm margin around the usable area - let uw = paper_w - 2.0 * margin; // usable width - let uh = paper_h - 2.0 * margin; // usable height - // Collect rectangle specs: (cx, cz, w, h) in mm + let ((x0, y0), (x1, y1)) = scene + .printable_area_limits() + .unwrap_or(((0.0, 0.0), (297.0, 210.0))); + let uw = (x1 - x0).max(1.0); + let uh = (y1 - y0).max(1.0); + let gap = 2.0 * scene.paper_space_unit_factor(); let rects: Vec<(f64, f64, f64, f64)> = match sub.as_str() { "2H" => { // Two viewports side by side (horizontal split) - let vw = (uw - 2.0) / 2.0; + let vw = (uw - gap) / 2.0; vec![ - (margin + vw / 2.0, margin + uh / 2.0, vw, uh), - (margin + vw + 2.0 + vw / 2.0, margin + uh / 2.0, vw, uh), + (x0 + vw / 2.0, y0 + uh / 2.0, vw, uh), + (x0 + vw + gap + vw / 2.0, y0 + uh / 2.0, vw, uh), ] } "2V" => { // Two viewports stacked (vertical split) - let vh = (uh - 2.0) / 2.0; + let vh = (uh - gap) / 2.0; vec![ - (margin + uw / 2.0, margin + vh + 2.0 + vh / 2.0, uw, vh), - (margin + uw / 2.0, margin + vh / 2.0, uw, vh), + (x0 + uw / 2.0, y0 + vh + gap + vh / 2.0, uw, vh), + (x0 + uw / 2.0, y0 + vh / 2.0, uw, vh), ] } "4" => { // Four equal viewports (2×2 grid) - let vw = (uw - 2.0) / 2.0; - let vh = (uh - 2.0) / 2.0; + let vw = (uw - gap) / 2.0; + let vh = (uh - gap) / 2.0; vec![ - (margin + vw / 2.0, margin + vh + 2.0 + vh / 2.0, vw, vh), + (x0 + vw / 2.0, y0 + vh + gap + vh / 2.0, vw, vh), ( - margin + vw + 2.0 + vw / 2.0, - margin + vh + 2.0 + vh / 2.0, + x0 + vw + gap + vw / 2.0, + y0 + vh + gap + vh / 2.0, vw, vh, ), - (margin + vw / 2.0, margin + vh / 2.0, vw, vh), - (margin + vw + 2.0 + vw / 2.0, margin + vh / 2.0, vw, vh), + (x0 + vw / 2.0, y0 + vh / 2.0, vw, vh), + (x0 + vw + gap + vw / 2.0, y0 + vh / 2.0, vw, vh), ] } "SINGLE" | "1" => { // Single full-page viewport - vec![(margin + uw / 2.0, margin + uh / 2.0, uw, uh)] + vec![(x0 + uw / 2.0, y0 + uh / 2.0, uw, uh)] } _ => { self.command_line.push_error( diff --git a/src/app/document.rs b/src/app/document.rs index 5893af52..3c866fac 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -256,12 +256,11 @@ impl DocumentTab { .unwrap_or(glam::DVec3::ZERO) } - /// True when the active pane edits **model-space** geometry: the Model tab, - /// or inside a floating viewport (MSPACE). The UCS applies in both; plain - /// paper space (no active viewport) is excluded. Single predicate so every - /// UCS-aware system shares one rule. [[feedback_shared_infra]] + /// True when the active pane has a drawing coordinate plane. pub(super) fn editing_model_space(&self) -> bool { - self.scene.current_layout == "Model" || self.scene.active_viewport.is_some() + self.scene.current_layout == "Model" + || self.scene.active_viewport.is_some() + || self.active_ucs.is_some() } /// The document's saved model-space UCS (header), as a `Ucs`. `None` when it @@ -321,9 +320,51 @@ impl DocumentTab { } } + pub(super) fn ucs_from_layout(&self) -> Option { + let layout = self.scene.document.objects.values().find_map(|object| { + let acadrust::objects::ObjectType::Layout(layout) = object else { + return None; + }; + (layout.name == self.scene.current_layout).then_some(layout) + })?; + let mut ucs = self + .scene + .document + .ucss + .iter() + .find(|ucs| ucs.handle == layout.named_ucs) + .cloned() + .unwrap_or_else(|| Ucs::new("*PAPERUCS*")); + ucs.origin = acadrust::types::Vector3::new( + layout.ucs_origin.0, + layout.ucs_origin.1, + layout.ucs_origin.2, + ); + ucs.x_axis = acadrust::types::Vector3::new( + layout.ucs_x_axis.0, + layout.ucs_x_axis.1, + layout.ucs_x_axis.2, + ); + ucs.y_axis = acadrust::types::Vector3::new( + layout.ucs_y_axis.0, + layout.ucs_y_axis.1, + layout.ucs_y_axis.2, + ); + ucs.elevation = layout.elevation; + ucs.ortho_type = layout.ucs_ortho_type; + ucs.named_ucs_handle = layout.named_ucs; + ucs.base_ucs_handle = layout.base_ucs; + let is_world = layout.named_ucs.is_null() + && layout.base_ucs.is_null() + && layout.ucs_ortho_type == 0 + && layout.elevation.abs() <= f64::EPSILON + && super::helpers::UcsXform::from_ucs(&ucs).is_identity(); + (!is_world).then_some(ucs) + } + /// Set `active_ucs` to the UCS of the *current pane*: the entered viewport's - /// own per-viewport UCS, the model header UCS in the Model tab, or none in - /// plain paper space. Keeps the ViewCube in lock-step. Call on every pane + /// own per-viewport UCS, the model header UCS, or the layout UCS. Keeps the + /// ViewCube in lock-step. Call on every pane /// change (enter/exit viewport, layout / tab switch, load) so one field /// drives all UCS-aware systems regardless of where editing happens. pub(super) fn refresh_active_ucs(&mut self) { @@ -334,15 +375,52 @@ impl DocumentTab { } else if self.scene.current_layout == "Model" { self.model_ucs_from_header() } else { - None + self.ucs_from_layout() }; + if self.active_block_edit.is_none() + && self.scene.active_viewport.is_none() + && self.scene.current_layout != "Model" + { + self.sync_paper_ucs_header(); + } self.sync_ucs_to_scene(); } + fn sync_paper_ucs_header(&mut self) { + use acadrust::types::Vector3; + let header = &mut self.scene.document.header; + match &self.active_ucs { + Some(ucs) => { + header.paper_space_ucs_origin = ucs.origin; + header.paper_space_ucs_x_axis = ucs.x_axis; + header.paper_space_ucs_y_axis = ucs.y_axis; + header.paper_elevation = ucs.elevation; + header.paper_space_ucs_name = if (ucs.named_ucs_handle.is_valid() + || ucs.handle.is_valid()) + && !ucs.name.starts_with('*') + { + ucs.name.clone() + } else { + String::new() + }; + header.paper_ucs_ortho_ref = ucs.base_ucs_handle; + header.paper_ucs_ortho_view = ucs.ortho_type; + } + None => { + header.paper_space_ucs_origin = Vector3::ZERO; + header.paper_space_ucs_x_axis = Vector3::UNIT_X; + header.paper_space_ucs_y_axis = Vector3::UNIT_Y; + header.paper_elevation = 0.0; + header.paper_space_ucs_name.clear(); + header.paper_ucs_ortho_ref = Handle::NULL; + header.paper_ucs_ortho_view = 0; + } + } + } + /// Persist `active_ucs` back to its pane's storage so it round-trips: the /// entered viewport's per-viewport UCS fields, or the document header's - /// model-space UCS in the Model tab. No-op in plain paper space. Call after - /// any UCS change. + /// model-space UCS in the Model tab, or the layout UCS. Call after a change. pub(super) fn persist_active_ucs(&mut self) { use acadrust::types::Vector3; if let Some(index) = self.active_block_edit { @@ -385,6 +463,48 @@ impl DocumentTab { h.model_space_ucs_name.clear(); } } + } else { + let (origin, x_axis, y_axis, elevation, ortho, named, base) = + match &self.active_ucs { + Some(ucs) => ( + ucs.origin, + ucs.x_axis, + ucs.y_axis, + ucs.elevation, + ucs.ortho_type, + if ucs.named_ucs_handle.is_valid() { + ucs.named_ucs_handle + } else { + ucs.handle + }, + ucs.base_ucs_handle, + ), + None => ( + Vector3::ZERO, + Vector3::UNIT_X, + Vector3::UNIT_Y, + 0.0, + 0, + Handle::NULL, + Handle::NULL, + ), + }; + for object in self.scene.document.objects.values_mut() { + let acadrust::objects::ObjectType::Layout(layout) = object else { + continue; + }; + if layout.name == self.scene.current_layout { + layout.ucs_origin = (origin.x, origin.y, origin.z); + layout.ucs_x_axis = (x_axis.x, x_axis.y, x_axis.z); + layout.ucs_y_axis = (y_axis.x, y_axis.y, y_axis.z); + layout.elevation = elevation; + layout.ucs_ortho_type = ortho; + layout.named_ucs = named; + layout.base_ucs = base; + break; + } + } + self.sync_paper_ucs_header(); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index d98a3958..8324cf55 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -810,16 +810,21 @@ pub(super) struct OpenCADStudio { /// Snapshot of the dialog's settings taken when it opened, restored by the /// `` list entry. plot_prev: Option, + /// Full source settings behind the fields currently shown in Plot. + plot_setup_template: Option, /// Paper layouts shown by Print All, in tab order with their selection. print_all_layouts: Vec<(String, bool)>, /// True while the Plot dialog is editing settings for Print All. print_all_options: bool, + /// True when Print All should override each layout's page setup. + print_all_settings_override: bool, /// Settings restored when the Print All options dialog is cancelled. print_all_options_prev: Option, /// Plot style restored together with cancelled Print All options. print_all_plot_style_prev: Option>, /// Plot window restored together with cancelled Print All options. print_all_plot_window_prev: Option>, + print_all_plot_setup_prev: Option>, // ── Plot Style Table ────────────────────────────────────────────────── /// Currently loaded CTB/STB table (None = no override). @@ -3287,11 +3292,14 @@ impl OpenCADStudio { plot_orientation: crate::io::paper_sizes::Orientation::Landscape, plot_dialog: crate::ui::window::plot::PlotDialogState::default(), plot_prev: None, + plot_setup_template: None, print_all_layouts: Vec::new(), print_all_options: false, + print_all_settings_override: false, print_all_options_prev: None, print_all_plot_style_prev: None, print_all_plot_window_prev: None, + print_all_plot_setup_prev: None, opening: None, open_job_serial: 0, recovery_report: None, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 3092b6d9..1f77d4bf 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -120,6 +120,32 @@ type ClippedPlotParams = ( Option<(f32, f32, f32, f32)>, ); +fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { + use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; + let orientation = if d.orientation == "Portrait" { + Orientation::Portrait + } else { + Orientation::Landscape + }; + let standard = match d.paper.as_str() { + "A3" => Some(PaperSize::A3), + "A2" => Some(PaperSize::A2), + "A1" => Some(PaperSize::A1), + "A0" => Some(PaperSize::A0), + "A4" => Some(PaperSize::A4), + _ => None, + }; + if let Some(paper) = standard { + return sheet_mm(paper, orientation); + } + let short = d.paper_width_mm.min(d.paper_height_mm).max(1.0); + let long = d.paper_width_mm.max(d.paper_height_mm).max(1.0); + match orientation { + Orientation::Portrait => (short, long), + Orientation::Landscape => (long, short), + } +} + fn plot_content_extents( wires: &[crate::io::pdf_export::PlotWire], hatches: &[crate::scene::model::hatch_model::HatchModel], @@ -169,7 +195,18 @@ fn plot_scene_content( crate::io::pdf_export::PlotGroupSplits, ) { let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override); - paper_wires.retain(|wire| wire.plot_visible); + let plot_viewport_borders = scene + .effective_plot_settings() + .is_none_or(|settings| settings.flags.plot_viewport_borders); + paper_wires.retain(|wire| { + wire.plot_visible + && (plot_viewport_borders + || !crate::scene::Scene::handle_from_wire_name(&wire.name) + .and_then(|handle| scene.document.get_entity(handle)) + .is_some_and(|entity| { + matches!(entity, acadrust::EntityType::Viewport(viewport) if crate::scene::Scene::is_content_viewport(viewport)) + })) + }); model_wires.retain(|wire| wire.plot_visible); let with_depth = |wires: Vec| { let depths = scene.plot_wire_depths(&wires); @@ -1364,6 +1401,8 @@ pub(super) fn on_open_file(&mut self) -> Task { .nth(1) .unwrap_or_else(|| "Model".to_string()), }; + self.tabs[i].scene.load_current_layout_state(); + self.tabs[i].refresh_active_ucs(); } // Object isolation is session-only. A newly opened drawing must // not inherit the previous tab's filter, and persisted entity @@ -2662,8 +2701,7 @@ pub(super) fn on_open_file(&mut self) -> Task { iced::exit() } - /// Write the given plot page settings into the active layout's Layout + - /// PlotSettings objects (paper size, plot area, offset, rotation, scale). + /// Write the given plot page settings into the active layout. /// No-op on the Model tab (which has no paper layout). Marks the tab dirty /// and re-tessellates the sheet. Called by the Plot dialog's Set current action. #[allow(clippy::too_many_arguments)] @@ -2684,118 +2722,97 @@ pub(super) fn on_open_file(&mut self) -> Task { if layout_name != "Model" { let w: f64 = w.max(1.0); let h: f64 = h.max(1.0); + use acadrust::objects::{ + PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode, + ShadePlotResolutionLevel, + }; + let mut ps = self + .plot_setup_template + .clone() + .or_else(|| self.tabs[i].scene.plot_settings_for(&layout_name)) + .unwrap_or_else(|| PlotSettings::new("")); + ps.paper_width = w; + ps.paper_height = h; + ps.paper_size = dialog.paper.clone(); + ps.plot_type = match plot_area { + "Window" => PlotType::Window, + "Display" => PlotType::LastScreenDisplay, + "Extents" => PlotType::Extents, + "Limits" => PlotType::Limits, + area if area.starts_with("View: ") => PlotType::View, + _ => PlotType::Layout, + }; + ps.plot_view_name = plot_area + .strip_prefix("View: ") + .unwrap_or("") + .to_string(); + if plot_area == "Window" { + if let Some((x0, y0, x1, y1)) = plot_window { + ps.set_plot_window(x0, y0, x1, y1); + } + } + ps.flags.plot_centered = center && plot_area != "Layout"; + ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x }; + ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y }; + ps.rotation = match rotation { + 90 => PlotRotation::Degrees90, + 180 => PlotRotation::Degrees180, + 270 => PlotRotation::Degrees270, + _ => PlotRotation::None, + }; + if dialog.fit_to_paper && plot_area != "Layout" { + ps.set_scale_to_fit(); + } else if plot_area == "Layout" { + ps.set_standard_scale(ScaledType::OneToOne); + ps.standard_scale_factor = 1.0; + } else { + let factor = plot_dialog_scale_factor(&dialog); + ps.scale_type = ScaledType::CustomScale; + ps.scale_numerator = factor; + ps.scale_denominator = 1.0; + ps.standard_scale_factor = factor; + ps.flags.use_standard_scale = false; + } + ps.printer_name = if dialog.to_file { + crate::ui::window::plot::OUT_PDF.into() + } else { + dialog.printer.clone().unwrap_or_default() + }; + ps.current_style_sheet = dialog.style_name.clone(); + ps.flags.scale_lineweights = dialog.scale_lw; + ps.flags.print_lineweights = dialog.lineweights; + ps.flags.plot_plot_styles = dialog.apply_plot_styles && !dialog.style_name.is_empty(); + ps.flags.show_plot_styles = dialog.show_plot_styles && !dialog.style_name.is_empty(); + ps.flags.draw_viewports_first = dialog.paperspace_last; + ps.flags.plot_hidden = dialog.shade == "Hidden Line"; + ps.shade_plot_mode = match dialog.shade.as_str() { + "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, + "Hidden Line" => ShadePlotMode::Hidden, + "As displayed" => ShadePlotMode::AsDisplayed, + _ => ShadePlotMode::Rendered, + }; + ps.shade_plot_resolution = match dialog.quality.as_str() { + "Low" => ShadePlotResolutionLevel::Draft, + "High" => ShadePlotResolutionLevel::Presentation, + _ => ShadePlotResolutionLevel::Normal, + }; + ps.shade_plot_dpi = 300; + + self.tabs[i] + .scene + .set_layout_plot_settings(&layout_name, &ps); for obj in self.tabs[i].scene.document.objects.values_mut() { - if let acadrust::objects::ObjectType::Layout(l) = obj { - if l.name == layout_name { - l.min_limits = (0.0, 0.0); - l.max_limits = (w, h); - l.min_extents = (0.0, 0.0, 0.0); - l.max_extents = (w, h, 0.0); - l.paper_width = w; - l.paper_height = h; - l.plot_rotation = 0; - l.plot_paper_units = 1; - l.plot_origin_x = offset_x; - l.plot_origin_y = offset_y; - l.paper_size = String::new(); + if let acadrust::objects::ObjectType::Layout(layout) = obj { + if layout.name == layout_name { + layout.min_limits = (0.0, 0.0); + layout.max_limits = (w, h); + layout.min_extents = (0.0, 0.0, 0.0); + layout.max_extents = (w, h, 0.0); break; } } } - use acadrust::objects::{ - ObjectType, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, - ShadePlotMode, ShadePlotResolutionLevel, - }; - let plot_handle = self.tabs[i] - .scene - .document - .objects - .iter() - .find_map(|(h, obj)| { - if let ObjectType::PlotSettings(ps) = obj { - (ps.page_name == layout_name).then_some(*h) - } else { - None - } - }); - - let ps_entry = if let Some(h) = plot_handle { - self.tabs[i].scene.document.objects.get_mut(&h) - } else { - let mut ps = PlotSettings::new(layout_name.clone()); - ps.handle = self.tabs[i].scene.document.allocate_handle(); - let h = ps.handle; - self.tabs[i] - .scene - .document - .objects - .insert(h, ObjectType::PlotSettings(ps)); - self.tabs[i].scene.document.objects.get_mut(&h) - }; - - if let Some(ObjectType::PlotSettings(ps)) = ps_entry { - ps.paper_width = w; - ps.paper_height = h; - ps.paper_size = dialog.paper.clone(); - ps.paper_units = PlotPaperUnits::Millimeters; - ps.plot_type = match plot_area { - "Window" => PlotType::Window, - "Display" => PlotType::LastScreenDisplay, - "Extents" => PlotType::Extents, - _ => PlotType::Layout, - }; - if plot_area == "Window" { - if let Some((x0, y0, x1, y1)) = plot_window { - ps.set_plot_window(x0, y0, x1, y1); - } - } - ps.flags.plot_centered = center && plot_area != "Layout"; - ps.origin_x = if plot_area == "Layout" { 0.0 } else { offset_x }; - ps.origin_y = if plot_area == "Layout" { 0.0 } else { offset_y }; - ps.rotation = match rotation { - 90 => PlotRotation::Degrees90, - 180 => PlotRotation::Degrees180, - 270 => PlotRotation::Degrees270, - _ => PlotRotation::None, - }; - if dialog.fit_to_paper && plot_area != "Layout" { - ps.set_scale_to_fit(); - } else { - let factor = if plot_area == "Layout" { - 1.0 - } else { - plot_dialog_scale_factor(&dialog) - }; - ps.scale_type = ScaledType::CustomScale; - ps.scale_numerator = factor; - ps.scale_denominator = 1.0; - } - ps.printer_name = if dialog.to_file { - crate::ui::window::plot::OUT_PDF.into() - } else { - dialog.printer.clone().unwrap_or_default() - }; - ps.current_style_sheet = dialog.style_name.clone(); - ps.flags.scale_lineweights = dialog.scale_lw; - ps.flags.print_lineweights = dialog.lineweights; - ps.flags.plot_plot_styles = !dialog.style_name.is_empty(); - ps.flags.draw_viewports_first = dialog.paperspace_last; - ps.flags.plot_hidden = false; - ps.shade_plot_mode = match dialog.shade.as_str() { - "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, - "Hidden Line" => ShadePlotMode::Hidden, - "As displayed" => ShadePlotMode::AsDisplayed, - _ => ShadePlotMode::Rendered, - }; - ps.shade_plot_resolution = match dialog.quality.as_str() { - "Low" => ShadePlotResolutionLevel::Draft, - "High" => ShadePlotResolutionLevel::Presentation, - _ => ShadePlotResolutionLevel::Normal, - }; - ps.shade_plot_dpi = 300; - } - self.tabs[i].dirty = true; self.tabs[i].scene.bump_geometry_no_blocks(); self.command_line.push_info(crate::tf!( @@ -2809,6 +2826,19 @@ pub(super) fn on_open_file(&mut self) -> Task { &mut self, path: std::path::PathBuf, ) -> Task { + let i = self.active_tab; + if self.tabs[i].scene.current_layout != "Model" { + self.plot_dialog.paper_space = true; + self.plot_dialog.scales = self.tabs[i] + .scene + .scale_list() + .into_iter() + .map(|(name, _, factor)| (name, factor)) + .collect(); + if let Some(settings) = self.tabs[i].scene.effective_plot_settings() { + self.load_plotsettings_into_dialog(&settings); + } + } let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = self.direct_plot_params() else { @@ -2911,6 +2941,7 @@ pub(super) fn on_open_file(&mut self) -> Task { .filter(|name| name != "Model") .map(|name| (name, true)) .collect(); + self.print_all_settings_override = false; self.active_modal = Some(crate::app::ModalKind::PrintAll); self.reset_modal_geometry(); Task::none() @@ -2920,10 +2951,12 @@ pub(super) fn on_open_file(&mut self) -> Task { let previous = self.plot_dialog.clone(); let previous_style = self.active_plot_style.clone(); let previous_window = self.plot_window; + let previous_setup = self.plot_setup_template.clone(); let task = self.on_plot_dialog_open(); self.print_all_options_prev = Some(previous); self.print_all_plot_style_prev = Some(previous_style); self.print_all_plot_window_prev = Some(previous_window); + self.print_all_plot_setup_prev = Some(previous_setup); self.print_all_options = true; self.plot_dialog.paper_space = true; self.plot_dialog.area = "Layout".into(); @@ -2945,47 +2978,145 @@ pub(super) fn on_open_file(&mut self) -> Task { let i = self.active_tab; let original_layout = self.tabs[i].scene.current_layout.clone(); let original_viewport = self.tabs[i].scene.active_viewport; - let dialog = self.plot_dialog.clone(); - let mut pages = Vec::with_capacity(selected.len()); - for name in selected { - // Plot helpers read the active layout. Swap only this transient - // selector so the drawing's saved active-space metadata is not - // touched while the owned page snapshot is collected. - { - let scene = &mut self.tabs[i].scene; - scene.current_layout = name; - scene.active_viewport = None; + let original_psltscale = self.tabs[i] + .scene + .document + .header + .paper_space_linetype_scaling; + let original_plimcheck = self.tabs[i] + .scene + .document + .header + .paper_space_limit_check; + let original_dialog = self.plot_dialog.clone(); + let original_style = self.active_plot_style.clone(); + let original_window = self.plot_window; + let original_setup = self.plot_setup_template.clone(); + let original_camera = self.tabs[i].scene.camera.borrow().clone(); + let original_camera_generation = self.tabs[i].scene.camera_generation; + let override_dialog = self.print_all_settings_override.then(|| original_dialog.clone()); + let override_style = self.print_all_settings_override.then(|| original_style.clone()); + let result = (|| { + let mut pages = Vec::with_capacity(selected.len()); + for name in selected { + let page_setup = self.tabs[i] + .scene + .plot_settings_for(&name) + .ok_or_else(|| format!("Layout '{name}' has no page setup."))?; + { + let scene = &mut self.tabs[i].scene; + scene.current_layout = name.clone(); + scene.active_viewport = None; + scene.load_current_layout_state(); + } + if let Some(dialog) = &override_dialog { + self.plot_dialog = dialog.clone(); + self.active_plot_style = override_style.clone().flatten(); + self.plot_window = original_window; + } else { + self.plot_dialog.paper_space = true; + self.plot_window = None; + self.load_plotsettings_into_dialog(&page_setup); + } + let dialog = self.plot_dialog.clone(); + if dialog.area == "Display" { + self.tabs[i].scene.restore_saved_camera(); + } + if dialog.style_missing && dialog.apply_plot_styles { + return Err(format!( + "Layout '{name}' plot style table '{}' is not loaded.", + dialog.style_name + )); + } + let plot_style = self.dialog_plot_style(&dialog); + let params = match dialog.area.as_str() { + "Display" => self.display_plot_job(), + "Extents" => self.extents_plot_job(), + "Limits" => self.limits_plot_job(), + "Window" => self.window_plot_job(), + area if area.starts_with("View: ") => { + self.named_view_plot_job(area.trim_start_matches("View: ")) + } + _ => None, + }; + let ( + wires, + hatches, + wipeouts, + group_splits, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + scale, + clip, + ) = if dialog.area == "Layout" { + self.layout_plot_params_for("Layout") + } else { + let ( + wires, + hatches, + wipeouts, + group_splits, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + scale, + clip, + ) = params.ok_or_else(|| format!("Layout '{name}' plot area is empty."))?; + ( + std::sync::Arc::new(wires), + hatches, + wipeouts, + group_splits, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + scale, + clip, + ) + }; + pages.push(crate::io::pdf_export::PdfPageInput { + wires, + hatches, + wipeouts, + paper_w, + paper_h, + offset_x, + offset_y, + rotation_deg, + scale, + clip, + options: Self::pdf_plot_options(&dialog, group_splits), + plot_style, + }); } - let ( - wires, - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) = self.layout_plot_params_for("Layout"); - pages.push(crate::io::pdf_export::PdfPageInput { - wires, - hatches, - wipeouts, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - options: Self::pdf_plot_options(&dialog, group_splits), - }); - } + Ok(pages) + })(); self.tabs[i].scene.current_layout = original_layout; self.tabs[i].scene.active_viewport = original_viewport; - Ok(pages) + self.tabs[i] + .scene + .document + .header + .paper_space_linetype_scaling = original_psltscale; + self.tabs[i] + .scene + .document + .header + .paper_space_limit_check = original_plimcheck; + self.plot_dialog = original_dialog; + self.active_plot_style = original_style; + self.plot_window = original_window; + self.plot_setup_template = original_setup; + *self.tabs[i].scene.camera.borrow_mut() = original_camera; + self.tabs[i].scene.camera_generation = original_camera_generation; + result } pub(super) fn on_print_all_pdf_path_some( @@ -2993,7 +3124,10 @@ pub(super) fn on_open_file(&mut self) -> Task { path: std::path::PathBuf, ) -> Task { let dialog = self.plot_dialog.clone(); - if dialog.style_missing { + if self.print_all_settings_override + && dialog.style_missing + && dialog.apply_plot_styles + { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", dialog.style_name @@ -3007,7 +3141,6 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } }; - let plot_style = self.dialog_plot_style(&dialog); let worker_path = path.clone(); self.save_config(); self.close_active_modal(); @@ -3015,7 +3148,7 @@ pub(super) fn on_open_file(&mut self) -> Task { crate::io::pdf_export::export_pdf_pages( &pages, &worker_path, - plot_style.as_ref(), + None, ) .map(|_| format!("Exported {} layouts to {}", pages.len(), worker_path.display())) .map_err(|error| format!("Export failed: {error}")) @@ -3034,7 +3167,10 @@ pub(super) fn on_open_file(&mut self) -> Task { #[cfg(not(target_arch = "wasm32"))] { let dialog = self.plot_dialog.clone(); - if dialog.style_missing { + if self.print_all_settings_override + && dialog.style_missing + && dialog.apply_plot_styles + { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", dialog.style_name @@ -3048,7 +3184,6 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } }; - let plot_style = self.dialog_plot_style(&dialog); let options = self.plot_print_options(&dialog, Default::default()); let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); @@ -3060,7 +3195,7 @@ pub(super) fn on_open_file(&mut self) -> Task { crate::io::pdf_export::export_pdf_pages( &pages, &temp_path, - plot_style.as_ref(), + None, ) .and_then(|_| { crate::io::print_to_printer::print_existing_pdf(&temp_path, &options) @@ -3112,23 +3247,10 @@ pub(super) fn on_open_file(&mut self) -> Task { } fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams { - use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; let i = self.active_tab; let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; - let selected_paper = match self.plot_dialog.paper.as_str() { - "A3" => PaperSize::A3, - "A2" => PaperSize::A2, - "A1" => PaperSize::A1, - "A0" => PaperSize::A0, - _ => PaperSize::A4, - }; - let selected_orientation = if self.plot_dialog.orientation == "Portrait" { - Orientation::Portrait - } else { - Orientation::Landscape - }; - let selected_sheet = sheet_mm(selected_paper, selected_orientation); + let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog); let (source_wires, hatches, wipeouts, mut group_splits) = plot_scene_content( scene, @@ -3463,14 +3585,21 @@ pub(super) fn on_open_file(&mut self) -> Task { let one_to_one = scale_name_for_factor(&scales, 1.0) .or_else(|| scales.first().map(|(name, _)| name.clone())) .unwrap_or_else(|| "1:1".into()); - // Keep the user's last print preferences (printer, copies, quality, - // output options — persisted in `self.plot_dialog`); only refresh the - // runtime printer list and reseed the drawing-specific fields from the - // active layout. + let paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; + let plot_views = self.tabs[self.active_tab] + .scene + .document + .views + .iter() + .filter(|view| view.paper_space == paper_space) + .map(|view| view.name.clone()) + .collect(); + // Keep session-only choices while loading drawing fields from the layout. let d = &mut self.plot_dialog; d.printers = crate::io::print_to_printer::list_printers(); d.plot_styles = crate::io::plot_style::available_ctb_names(); d.scales = scales; + d.plot_views = plot_views; if d.scale.eq_ignore_ascii_case("fit") { d.fit_to_paper = true; d.scale_lw = false; @@ -3478,7 +3607,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } else if !d.scales.iter().any(|(name, _)| name == &d.scale) { d.scale = one_to_one.clone(); } - d.paper_space = self.tabs[self.active_tab].scene.current_layout != "Model"; + d.paper_space = paper_space; d.paper = self.plot_format.label().to_string(); d.orientation = match self.plot_orientation { Orientation::Portrait => "Portrait", @@ -3510,32 +3639,24 @@ pub(super) fn on_open_file(&mut self) -> Task { .map(|t| t.name.clone()) .unwrap_or_default(); d.style_missing = false; - // `area`, scale and fit are remembered user choices, - // not reseeded from the layout. Offset / center / rotation ARE layout - // properties, so reflect them. - if let Some(ps) = self.tabs[self.active_tab].scene.effective_plot_settings() { - let d = &mut self.plot_dialog; - d.center = ps.flags.plot_centered; - d.offset_x = format!("{:.2}", ps.origin_x); - d.offset_y = format!("{:.2}", ps.origin_y); - let deg = ps.rotation.to_degrees() as i32; - d.upside_down = matches!(deg, 180 | 270); - } self.plot_dialog.name_input = None; self.plot_dialog.name_rename = false; if self.plot_dialog.fit_to_paper { self.plot_dialog.scale_lw = false; } - // Refresh document/runtime lists, then restore the live choices from - // the preceding dialog session. Previously the snapshot happened - // after the layout values above were reloaded, so opening Plot itself - // destroyed the user's last paper, area, scale and output choices. self.refresh_page_setups(); self.plot_prev = Some(previous); - self.select_page_setup(crate::ui::window::plot::SETUP_PREV); - // `` may come from another tab/drawing whose custom scale is - // not present in this document. Validate again after restoring it; the - // pre-restore validation above only saw the temporarily reseeded state. + let cur = self.tabs[self.active_tab].scene.current_layout.clone(); + let layout_entry = format!("*{cur}*"); + if self.tabs[self.active_tab] + .scene + .plot_settings_for(&cur) + .is_some() + { + self.select_page_setup(&layout_entry); + } else { + self.select_page_setup(crate::ui::window::plot::SETUP_PREV); + } if self.plot_dialog.scale.eq_ignore_ascii_case("fit") { self.plot_dialog.fit_to_paper = true; self.plot_dialog.scale = one_to_one.clone(); @@ -3550,10 +3671,7 @@ pub(super) fn on_open_file(&mut self) -> Task { if self.plot_dialog.fit_to_paper { self.plot_dialog.scale_lw = false; } - // A model-space plot cannot use the paper-only Layout area. Other - // values remain exactly as the user left them; selecting a layout or - // named setup explicitly still reloads that setup below. - let cur = self.tabs[self.active_tab].scene.current_layout.clone(); + // A model-space plot cannot use the paper-only Layout area. if cur == "Model" { if self.plot_dialog.area == "Layout" { self.plot_dialog.area = "Window".into(); @@ -3608,10 +3726,16 @@ pub(super) fn on_open_file(&mut self) -> Task { } M::Paper(s) => { self.plot_dialog.paper = s; + let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog); + self.plot_dialog.paper_width_mm = w; + self.plot_dialog.paper_height_mm = h; Task::none() } M::Orientation(s) => { self.plot_dialog.orientation = s; + let (w, h) = plot_dialog_sheet_mm(&self.plot_dialog); + self.plot_dialog.paper_width_mm = w; + self.plot_dialog.paper_height_mm = h; Task::none() } M::Area(s) => { @@ -3681,6 +3805,12 @@ pub(super) fn on_open_file(&mut self) -> Task { PlotFlag::ScaleLw if !d.fit_to_paper => { d.scale_lw = !d.scale_lw } + PlotFlag::PlotStyles if !d.style_name.is_empty() => { + d.apply_plot_styles = !d.apply_plot_styles + } + PlotFlag::DisplayStyles if d.paper_space && !d.style_name.is_empty() => { + d.show_plot_styles = !d.show_plot_styles + } PlotFlag::UpsideDown => { d.upside_down = !d.upside_down; } @@ -3700,11 +3830,14 @@ pub(super) fn on_open_file(&mut self) -> Task { if name == STYLE_NONE { self.active_plot_style = None; self.plot_dialog.style_name.clear(); + self.plot_dialog.apply_plot_styles = false; + self.plot_dialog.show_plot_styles = false; self.plot_dialog.style_missing = false; } else { match crate::io::plot_style::PlotStyleTable::load_named(&name) { Ok(table) => { self.plot_dialog.style_name = table.name.clone(); + self.plot_dialog.apply_plot_styles = true; self.plot_dialog.style_missing = false; self.active_plot_style = Some(table); } @@ -3852,7 +3985,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } M::Preview => self.on_plot_dlg_commit(true), M::Commit if self.print_all_options => { - if self.plot_dialog.style_missing { + if self.plot_dialog.style_missing && self.plot_dialog.apply_plot_styles { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", self.plot_dialog.style_name @@ -3863,9 +3996,11 @@ pub(super) fn on_open_file(&mut self) -> Task { self.plot_dialog.area = "Layout".into(); self.sync_dialog_plot_runtime(); self.save_config(); + self.print_all_settings_override = true; self.print_all_options = false; self.print_all_options_prev = None; self.print_all_plot_style_prev = None; + self.print_all_plot_setup_prev = None; if let Some(previous) = self.print_all_plot_window_prev.take() { self.plot_window = previous; } @@ -3894,12 +4029,15 @@ pub(super) fn on_open_file(&mut self) -> Task { use crate::ui::window::plot::{SETUP_NONE, SETUP_PREV}; self.plot_dialog.selected_setup = name.to_string(); if name == SETUP_NONE { + self.plot_setup_template = None; // No page setup: default geometry + PDF output. let is_model = self.tabs[self.active_tab].scene.current_layout == "Model"; let d = &mut self.plot_dialog; d.to_file = true; d.paper = "A4".into(); d.orientation = "Landscape".into(); + d.paper_width_mm = 297.0; + d.paper_height_mm = 210.0; d.area = if is_model { "Window".into() } else { "Layout".into() }; d.center = true; d.offset_x = "0.0".into(); @@ -3947,32 +4085,32 @@ pub(super) fn on_open_file(&mut self) -> Task { PlotPaperUnits, PlotRotation, PlotSettings, PlotType, ScaledType, ShadePlotMode, ShadePlotResolutionLevel, }; - use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; let d = &self.plot_dialog; - let paper = match d.paper.as_str() { - "A3" => PaperSize::A3, - "A2" => PaperSize::A2, - "A1" => PaperSize::A1, - "A0" => PaperSize::A0, - _ => PaperSize::A4, + let (w, h) = plot_dialog_sheet_mm(d); + let mut ps = match self.plot_setup_template.clone() { + Some(settings) => settings, + None => { + let mut settings = PlotSettings::new(""); + settings.paper_units = PlotPaperUnits::Millimeters; + settings + } }; - let orient = if d.orientation == "Portrait" { - Orientation::Portrait - } else { - Orientation::Landscape - }; - let (w, h) = sheet_mm(paper, orient); - let mut ps = PlotSettings::new(""); ps.paper_width = w; ps.paper_height = h; ps.paper_size = d.paper.clone(); - ps.paper_units = PlotPaperUnits::Millimeters; ps.plot_type = match d.area.as_str() { "Window" => PlotType::Window, "Layout" => PlotType::Layout, "Display" => PlotType::LastScreenDisplay, + "Limits" => PlotType::Limits, + area if area.starts_with("View: ") => PlotType::View, _ => PlotType::Extents, }; + ps.plot_view_name = d + .area + .strip_prefix("View: ") + .unwrap_or("") + .to_string(); if d.area == "Window" { if let Some((x0, y0, x1, y1)) = self.plot_window { ps.set_plot_window(x0, y0, x1, y1); @@ -3998,6 +4136,7 @@ pub(super) fn on_open_file(&mut self) -> Task { }; if d.area == "Layout" { ps.set_standard_scale(ScaledType::OneToOne); + ps.standard_scale_factor = 1.0; } else if d.fit_to_paper { ps.set_scale_to_fit(); } else { @@ -4005,6 +4144,8 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.scale_type = ScaledType::CustomScale; ps.scale_numerator = factor; ps.scale_denominator = 1.0; + ps.standard_scale_factor = factor; + ps.flags.use_standard_scale = false; } ps.printer_name = if d.to_file { crate::ui::window::plot::OUT_PDF.into() @@ -4014,9 +4155,10 @@ pub(super) fn on_open_file(&mut self) -> Task { ps.current_style_sheet = d.style_name.clone(); ps.flags.scale_lineweights = d.scale_lw; ps.flags.print_lineweights = d.lineweights; - ps.flags.plot_plot_styles = !d.style_name.is_empty(); + ps.flags.plot_plot_styles = d.apply_plot_styles && !d.style_name.is_empty(); + ps.flags.show_plot_styles = d.show_plot_styles && !d.style_name.is_empty(); ps.flags.draw_viewports_first = d.paperspace_last; - ps.flags.plot_hidden = false; + ps.flags.plot_hidden = d.shade == "Hidden Line"; ps.shade_plot_mode = match d.shade.as_str() { "2D Wireframe" | "3D Wireframe" => ShadePlotMode::Wireframe, "Hidden Line" => ShadePlotMode::Hidden, @@ -4037,6 +4179,7 @@ pub(super) fn on_open_file(&mut self) -> Task { use acadrust::objects::{ PlotType, ShadePlotMode, ShadePlotResolutionLevel, }; + self.plot_setup_template = Some(ps.clone()); if matches!(ps.plot_type, PlotType::Window) && !ps.plot_window.is_empty() { self.plot_window = Some(( ps.plot_window.lower_left_x, @@ -4059,7 +4202,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } let active_style_name = self.active_plot_style.as_ref().map(|table| table.name.clone()); let style_name = if ps.current_style_sheet.is_empty() { - active_style_name.clone().unwrap_or_default() + String::new() } else if active_style_name .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&ps.current_style_sheet)) @@ -4072,17 +4215,30 @@ pub(super) fn on_open_file(&mut self) -> Task { && active_style_name .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&style_name)); - let (paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); + let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); + if !matches!(paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0") + && !ps.paper_size.is_empty() + { + paper = ps.paper_size.clone(); + } let d = &mut self.plot_dialog; d.paper = paper; + d.paper_width_mm = ps.paper_width.max(1.0); + d.paper_height_mm = ps.paper_height.max(1.0); d.orientation = orient; d.area = match ps.plot_type { - PlotType::Window => "Window", - PlotType::Layout => "Layout", - PlotType::LastScreenDisplay => "Display", - _ => "Extents", - } - .to_string(); + PlotType::Window => "Window".to_string(), + PlotType::Layout => "Layout".to_string(), + PlotType::LastScreenDisplay => "Display".to_string(), + PlotType::Limits => "Limits".to_string(), + PlotType::View if !ps.plot_view_name.is_empty() => { + d.plot_views.push(ps.plot_view_name.clone()); + d.plot_views.sort(); + d.plot_views.dedup(); + format!("View: {}", ps.plot_view_name) + } + _ => "Extents".to_string(), + }; if !d.paper_space && d.area == "Layout" { d.area = "Extents".into(); } @@ -4102,6 +4258,12 @@ pub(super) fn on_open_file(&mut self) -> Task { d.fit_to_paper = d.area != "Layout" && ps.is_scale_to_fit(); let target_factor = if d.area == "Layout" { 1.0 + } else if ps.flags.use_standard_scale { + if ps.standard_scale_factor.is_finite() && ps.standard_scale_factor > 0.0 { + ps.standard_scale_factor + } else { + ps.scale_type.scale_factor() + } } else if ps.scale_denominator.abs() > 1e-9 { ps.scale_numerator / ps.scale_denominator } else { @@ -4116,11 +4278,15 @@ pub(super) fn on_open_file(&mut self) -> Task { d.scale_lw = ps.flags.scale_lineweights && !d.fit_to_paper; d.lineweights = ps.flags.print_lineweights; d.paperspace_last = ps.flags.draw_viewports_first; - d.shade = match ps.shade_plot_mode { - ShadePlotMode::Wireframe => "2D Wireframe", - ShadePlotMode::Hidden => "Hidden Line", - ShadePlotMode::Rendered => "Gouraud Shaded", - ShadePlotMode::AsDisplayed => "As displayed", + d.shade = if ps.flags.plot_hidden { + "Hidden Line" + } else { + match ps.shade_plot_mode { + ShadePlotMode::Wireframe => "2D Wireframe", + ShadePlotMode::Hidden => "Hidden Line", + ShadePlotMode::Rendered => "Gouraud Shaded", + ShadePlotMode::AsDisplayed => "As displayed", + } } .into(); d.quality = match ps.shade_plot_resolution { @@ -4139,6 +4305,8 @@ pub(super) fn on_open_file(&mut self) -> Task { d.printer = (!ps.printer_name.is_empty()).then(|| ps.printer_name.clone()); } d.style_name = style_name; + d.apply_plot_styles = ps.flags.plot_plot_styles; + d.show_plot_styles = ps.flags.show_plot_styles; d.style_missing = !d.style_name.is_empty() && !style_loaded; } @@ -4164,10 +4332,9 @@ pub(super) fn on_open_file(&mut self) -> Task { } fn apply_dialog_to_layout(&mut self) { - use crate::io::paper_sizes::sheet_mm; self.sync_dialog_plot_runtime(); let d = self.plot_dialog.clone(); - let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); + let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&d); let rotation: i16 = if d.upside_down { 180 } else { 0 }; let off_x = d.offset_x.parse::().unwrap_or(0.0); let off_y = d.offset_y.parse::().unwrap_or(0.0); @@ -4185,7 +4352,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Open a preview PDF, export a PDF, or send the job to the chosen printer. fn on_plot_dlg_commit(&mut self, preview: bool) -> Task { let d = self.plot_dialog.clone(); - if d.style_missing { + if d.style_missing && d.apply_plot_styles { self.command_line.push_error(crate::tf!( "Plot style table '{}' is not loaded.", d.style_name @@ -4203,11 +4370,16 @@ pub(super) fn on_open_file(&mut self) -> Task { let plot_style = self.dialog_plot_style(&d); // Extents, Window and Display use one plot path in both spaces. Only // Paper-space Layout is special: it uses the physical sheet bounds. - if matches!(d.area.as_str(), "Extents" | "Window" | "Display") { + if d.area != "Layout" { let job = match d.area.as_str() { "Display" => self.display_plot_job(), "Extents" => self.extents_plot_job(), - _ => self.window_plot_job(), + "Limits" => self.limits_plot_job(), + "Window" => self.window_plot_job(), + area if area.starts_with("View: ") => { + self.named_view_plot_job(area.trim_start_matches("View: ")) + } + _ => None, }; let Some(( w_wires, @@ -4341,7 +4513,7 @@ pub(super) fn on_open_file(&mut self) -> Task { &self, d: &crate::ui::window::plot::PlotDialogState, ) -> Option { - if d.style_name.is_empty() || d.style_missing { + if d.style_name.is_empty() || d.style_missing || !d.apply_plot_styles { return None; } self.active_plot_style @@ -4358,6 +4530,34 @@ pub(super) fn on_open_file(&mut self) -> Task { self.area_plot_job(self.display_plot_window()?) } + fn limits_plot_job(&self) -> Option { + let (min, max) = self.tabs[self.active_tab] + .scene + .current_drawing_limits()?; + self.area_plot_job((min.x, min.y, max.x, max.y)) + } + + fn named_view_plot_job(&self, name: &str) -> Option { + let view = self.tabs[self.active_tab] + .scene + .document + .views + .iter() + .find(|view| { + view.name.eq_ignore_ascii_case(name) + && view.paper_space == (self.tabs[self.active_tab].scene.current_layout != "Model") + })?; + let half_w = view.width.abs() * 0.5; + let half_h = view.height.abs() * 0.5; + (half_w > 1e-9 && half_h > 1e-9).then_some(())?; + self.area_plot_job(( + view.center.x - half_w, + view.center.y - half_h, + view.center.x + half_w, + view.center.y + half_h, + )) + } + fn extents_plot_job(&self) -> Option { let scene = &self.tabs[self.active_tab].scene; if scene.current_layout == "Model" { @@ -4446,13 +4646,13 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Render a selected rectangle through one shared Model/Paper path. The /// window may lie partly or wholly outside a paper sheet. fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { - use crate::io::paper_sizes::{sheet_mm, window_to_sheet, PlotScale}; + use crate::io::paper_sizes::{window_to_sheet, PlotScale}; let i = self.active_tab; let (x0, y0, x1, y1) = window; if (x1 - x0) < 1e-6 || (y1 - y0) < 1e-6 { return None; } - let (sheet_w, sheet_h) = sheet_mm(self.plot_format, self.plot_orientation); + let (sheet_w, sheet_h) = plot_dialog_sheet_mm(&self.plot_dialog); let win_w = (x1 - x0).max(1e-9); let win_h = (y1 - y0).max(1e-9); let scale_sel = if self.plot_dialog.fit_to_paper { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 67f37af0..a98bb676 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -151,6 +151,9 @@ impl OpenCADStudio { if let Some(previous) = self.print_all_plot_window_prev.take() { self.plot_window = previous; } + if let Some(previous) = self.print_all_plot_setup_prev.take() { + self.plot_setup_template = previous; + } self.print_all_options = false; self.active_modal = Some(PrintAll); self.reset_modal_geometry(); @@ -6917,6 +6920,9 @@ impl OpenCADStudio { self.plot_dialog.style_missing = false; self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); + self.tabs[self.active_tab] + .scene + .invalidate_display_plot_style(); self.command_line.push_output( crate::tf!( @@ -6968,6 +6974,9 @@ impl OpenCADStudio { self.plot_dialog.style_missing = false; self.plot_dialog.plot_styles = crate::io::plot_style::available_ctb_names(); + self.tabs[self.active_tab] + .scene + .invalidate_display_plot_style(); self.command_line.push_output(crate::tf!( "Plot style table saved to \"{}\".", path.display() diff --git a/src/app/update/util.rs b/src/app/update/util.rs index 3a01d394..8779ffc2 100644 --- a/src/app/update/util.rs +++ b/src/app/update/util.rs @@ -62,8 +62,7 @@ pub(super) fn layout_entry_name(s: &str) -> &str { s.trim_start_matches('*').trim_end_matches('*') } -/// Infer the closest A-series paper label and orientation from sheet -/// dimensions (mm). Falls back to A4 when nothing is close. +/// Infer an A-series label when dimensions match; otherwise retain the size. pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) { use crate::io::paper_sizes::PaperSize; let orient = if w >= h { "Landscape" } else { "Portrait" }; @@ -76,7 +75,12 @@ pub(super) fn paper_label_from_dims(w: f64, h: f64) -> (String, String) { best = (p.label().to_string(), err); } } - (best.0, orient.to_string()) + let label = if best.1 <= 2.0 { + best.0 + } else { + format!("{w:.2} × {h:.2} mm") + }; + (label, orient.to_string()) } pub(super) fn parse_plot_scale(s: &str) -> (f64, f64) { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index da0615fe..a527895b 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -5293,8 +5293,7 @@ impl OpenCADStudio { self.tabs[i].scene.set_current_layout(name.clone()); self.tabs[i].scene.deselect_all(); let switch_ms = perf_phase.elapsed().as_secs_f64() * 1000.0; - // UCS follows the pane: model header UCS in the Model tab, none - // in plain paper space (a viewport's UCS is adopted on entry). + // UCS follows the active model, layout, or floating viewport pane. let perf_phase = Instant::now(); self.tabs[i].refresh_active_ucs(); self.tabs[i].scene.restore_saved_camera(); @@ -5358,10 +5357,28 @@ impl OpenCADStudio { self.push_undo_snapshot(i, "LAYOUT"); match self.tabs[i].scene.document.add_layout(&new_name) { Ok(_) => { - // Override the acadrust default limits (12×9 imperial) with A4 landscape. + let layout_flags = i16::from( + self.tabs[i] + .scene + .document + .header + .paper_space_linetype_scaling, + ) | (i16::from( + self.tabs[i] + .scene + .document + .header + .paper_space_limit_check, + ) << 1); + let plot_style = self + .active_plot_style + .as_ref() + .map(|style| style.name.clone()) + .unwrap_or_default(); for obj in self.tabs[i].scene.document.objects.values_mut() { if let acadrust::objects::ObjectType::Layout(l) = obj { if l.name == new_name { + l.flags = layout_flags; l.min_limits = (0.0, 0.0); l.max_limits = (297.0, 210.0); l.min_extents = (0.0, 0.0, 0.0); @@ -5371,6 +5388,14 @@ impl OpenCADStudio { l.plot_paper_units = 1; l.plot_scale_numerator = 1.0; l.plot_scale_denominator = 1.0; + l.plot_scale_type = 16; + l.plot_scale_factor = 1.0; + l.plot_type = 5; + l.plot_flags.use_standard_scale = true; + l.plot_flags.print_lineweights = true; + l.plot_flags.plot_plot_styles = !plot_style.is_empty(); + l.plot_flags.show_plot_styles = !plot_style.is_empty(); + l.plot_style_sheet = plot_style; l.paper_size = "ISO_A4_(297.00_x_210.00_MM)".into(); break; } diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 3f8c5d2d..bd32fd03 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -25,6 +25,7 @@ use std::io::Write; use std::path::Path; #[derive(Clone, Debug)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub struct PlotWire { pub wire: WireModel, pub draw_depth: f32, @@ -120,6 +121,7 @@ pub struct PdfPageInput { pub scale: f32, pub clip: Option<(f32, f32, f32, f32)>, pub options: PdfPlotOptions, + pub plot_style: Option, } impl Default for PdfPlotOptions { @@ -270,7 +272,7 @@ fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) page.rotation_deg, page.scale, page.clip, - plot_style, + page.plot_style.as_ref().or(plot_style), page.options, ); } @@ -440,6 +442,8 @@ fn append_pdf_page( let mut last_color: Option<[f32; 3]> = None; let mut last_lw: Option = None; + let mut last_cap = Some(LineCapStyle::Round); + let mut last_join = Some(LineJoinStyle::Round); // Current PDF dash array (empty = solid). Tracked so the dash op is only // re-emitted when it actually changes between wires. let mut last_dash: Option> = None; @@ -453,7 +457,9 @@ fn append_pdf_page( ox, oy, plot_style, + scale, options, + normal_blend.as_ref(), ); last_color = None; last_lw = None; @@ -506,6 +512,8 @@ fn append_pdf_page( let mut lw_override: Option = None; let mut screening = 1.0; let mut color_overridden = false; + let mut cap = None; + let mut join = None; if let Some(ctb) = plot_style { if wire.aci > 0 { if let Some([cr, cg, cb]) = ctb.resolve_color(wire.aci) { @@ -518,6 +526,20 @@ fn append_pdf_page( .resolve_lineweight(wire.aci) .map(|mm| (mm * MM_TO_PT).max(0.1)); screening = ctb.resolve_screening(wire.aci); + if let Some(entry) = ctb.aci_entries.get(wire.aci as usize) { + cap = match entry.end_style { + 0 => Some(LineCapStyle::Butt), + 1 | 3 => Some(LineCapStyle::ProjectingSquare), + 2 => Some(LineCapStyle::Round), + _ => None, + }; + join = match entry.join_style { + 0 => Some(LineJoinStyle::Miter), + 1 | 3 => Some(LineJoinStyle::Bevel), + 2 => Some(LineJoinStyle::Round), + _ => None, + }; + } } } // Near-white and near-yellow (viewport active border) → dark grey for print @@ -539,6 +561,17 @@ fn append_pdf_page( } [r, g, b] = plotted_color([r, g, b], a, screening, options); + let cap = cap.unwrap_or(LineCapStyle::Round); + if last_cap != Some(cap) { + ops.push(Op::SetLineCapStyle { cap }); + last_cap = Some(cap); + } + let join = join.unwrap_or(LineJoinStyle::Round); + if last_join != Some(join) { + ops.push(Op::SetLineJoinStyle { join }); + last_join = Some(join); + } + if last_color .map(|c| (c[0] - r).abs() > 0.01 || (c[1] - g).abs() > 0.01 || (c[2] - b).abs() > 0.01) .unwrap_or(true) @@ -576,13 +609,11 @@ fn append_pdf_page( let lw_pt = if wire.world_width > 0.0 { wire.world_width * MM_TO_PT } else { - let physical = lw_override.unwrap_or_else(|| { - if options.object_lineweights { - (wire.line_weight_px * LW_PX_TO_PT).max(0.1) - } else { - 0.1 - } - }); + let physical = if options.object_lineweights { + lw_override.unwrap_or_else(|| (wire.line_weight_px * LW_PX_TO_PT).max(0.1)) + } else { + 0.1 + }; physical / pen_divisor }; if last_lw.map(|l| (l - lw_pt).abs() > 0.01).unwrap_or(true) { @@ -852,12 +883,71 @@ fn emit_wire_fills( ox: f64, oy: f64, plot_style: Option<&PlotStyleTable>, + scale: f32, options: PdfPlotOptions, + normal_blend: Option<&ExtendedGraphicsStateId>, ) { for wire in wires { if wire.fill_tris.is_empty() { continue; } + let styled_pattern = plot_style.and_then(|table| { + (wire.aci > 0) + .then(|| table.aci_entries.get(wire.aci as usize)) + .flatten() + .and_then(|entry| { + (65..=72) + .contains(&entry.fill_style) + .then(|| { + crate::scene::model::hatch_model::plot_style_fill_pattern( + entry.fill_style, + ) + }) + .flatten() + }) + }); + if let Some(pattern) = styled_pattern { + for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() { + let mut boundary = Vec::with_capacity(4); + for (point_index, point) in triangle.iter().enumerate() { + let index = triangle_index * 3 + point_index; + let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]); + boundary.push([point[0] + low[0], point[1] + low[1]]); + } + boundary.push(boundary[0]); + let hatch = HatchModel { + render_instance: wire.render_instance.clone(), + world_origin: [0.0, 0.0], + boundary: std::sync::Arc::new(boundary), + boundary_wcs: None, + fill_plane: None, + fill_plane_boundary: None, + boundary_exterior: None, + boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, + pattern: pattern.clone(), + name: "PLOTSTYLE".to_string(), + color: wire.color, + aci: wire.aci, + line_weight_px: wire.line_weight_px, + angle_offset: 0.0, + scale: 1.0 / scale.max(1.0e-6), + draw_depth: wire.depth_override.unwrap_or(0.0), + }; + emit_hatch( + ops, + &hatch, + ox, + oy, + plot_style, + scale, + options, + normal_blend, + ); + } + continue; + } let [mut r, mut g, mut b, a] = wire.color; if a < 0.01 { continue; @@ -964,6 +1054,26 @@ fn emit_hatch( if hatch.boundary.is_empty() { return; } + let mut styled_hatch = None; + if let Some(table) = plot_style { + if hatch.aci > 0 && matches!(hatch.pattern, HatchPattern::Solid) { + if let Some(pattern) = table + .aci_entries + .get(hatch.aci as usize) + .and_then(|entry| { + crate::scene::model::hatch_model::plot_style_fill_pattern( + entry.fill_style, + ) + }) + { + let mut model = hatch.clone(); + model.pattern = pattern; + model.scale = 1.0 / scale.max(1.0e-6); + styled_hatch = Some(model); + } + } + } + let hatch = styled_hatch.as_ref().unwrap_or(hatch); let [mut r, mut g, mut b, a] = hatch.color; if a < 0.01 { return; @@ -1082,13 +1192,11 @@ fn emit_hatch( // Pattern hatches: rasterise the family lines clipped to the boundary // and emit each as a stroked line. Skips the polygon outline entirely. if matches!(hatch.pattern, HatchPattern::Pattern(_)) { - let physical = lw_override.unwrap_or_else(|| { - if options.object_lineweights { - (hatch.line_weight_px * LW_PX_TO_PT).max(0.1) - } else { - 0.1 - } - }); + let physical = if options.object_lineweights { + lw_override.unwrap_or_else(|| (hatch.line_weight_px * LW_PX_TO_PT).max(0.1)) + } else { + 0.1 + }; let divisor = if options.scale_lineweights { 1.0 } else { @@ -1248,16 +1356,16 @@ fn emit_text( if let Some(ctb) = plot_style { if wire.aci > 0 { ctb_color = ctb.resolve_color(wire.aci); - lw_override = ctb - .resolve_lineweight(wire.aci) - .map(|mm| { + lw_override = options.object_lineweights.then(|| { + ctb.resolve_lineweight(wire.aci).map(|mm| { let divisor = if options.scale_lineweights { 1.0 } else { scale.max(1e-6) }; (mm * MM_TO_PT).max(0.1) / divisor - }); + }) + }).flatten(); screening = ctb.resolve_screening(wire.aci); } } diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index a1e01360..f44eb88c 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -1,7 +1,7 @@ //! Plot Style Table — CTB (color-based) and STB (named) file support. //! //! CTB files map indexed drawing colors (ACI, 1-255) to pen properties: -//! RGB color override, lineweight, and screeing percentage. +//! RGB color override, lineweight, and screening percentage. //! //! File format: a fixed 60-byte header followed by zlib-compressed text. //! @@ -17,6 +17,21 @@ use std::path::PathBuf; pub const DEFAULT_PLOT_STYLE: &str = "ocad.ctb"; pub const MONOCHROME_PLOT_STYLE: &str = "monochrome.ctb"; +pub const GRAYSCALE_PLOT_STYLE: &str = "Grayscale.ctb"; +pub const FILL_PATTERNS_PLOT_STYLE: &str = "Fill Patterns.ctb"; +pub const SCREENING_100_PLOT_STYLE: &str = "Screening 100%.ctb"; +pub const SCREENING_75_PLOT_STYLE: &str = "Screening 75%.ctb"; +pub const SCREENING_50_PLOT_STYLE: &str = "Screening 50%.ctb"; +pub const SCREENING_25_PLOT_STYLE: &str = "Screening 25%.ctb"; + +const STANDARD_PLOT_STYLES: &[&str] = &[ + GRAYSCALE_PLOT_STYLE, + FILL_PATTERNS_PLOT_STYLE, + SCREENING_100_PLOT_STYLE, + SCREENING_75_PLOT_STYLE, + SCREENING_50_PLOT_STYLE, + SCREENING_25_PLOT_STYLE, +]; #[cfg(not(target_arch = "wasm32"))] pub fn plot_styles_dir() -> Result { @@ -52,10 +67,14 @@ pub fn available_ctb_names() -> Vec { #[cfg(not(target_arch = "wasm32"))] { let Ok(dir) = ensure_plot_styles_dir() else { - return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + return names; }; let Ok(entries) = std::fs::read_dir(dir) else { - return vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + return names; }; let mut names: Vec = entries .filter_map(Result::ok) @@ -67,13 +86,16 @@ pub fn available_ctb_names() -> Vec { .then(|| entry.file_name().to_string_lossy().into_owned()) }) .collect(); + names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); names.sort_by_key(|name| name.to_ascii_lowercase()); names.dedup_by(|left, right| left.eq_ignore_ascii_case(right)); names } #[cfg(target_arch = "wasm32")] { - vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()] + let mut names = vec![DEFAULT_PLOT_STYLE.into(), MONOCHROME_PLOT_STYLE.into()]; + names.extend(STANDARD_PLOT_STYLES.iter().map(|name| (*name).to_string())); + names } } @@ -199,10 +221,44 @@ impl PlotStyleTable { MONOCHROME_PLOT_STYLE, include_bytes!("../../assets/plotstyles/monochrome.ctb"), ), + "grayscale.ctb" => { + let mut table = Self::identity(GRAYSCALE_PLOT_STYLE); + for aci in 1..=255u8 { + if let Some((r, g, b)) = acadrust::types::aci_to_rgb(aci) { + let gray = if aci == 7 { + 0 + } else { + (0.299 * r as f32 + 0.587 * g as f32 + 0.114 * b as f32) + .round() as u8 + }; + table.aci_entries[aci as usize].color = Some([gray, gray, gray]); + } + } + Ok(table) + } + "fill patterns.ctb" => { + let mut table = Self::identity(FILL_PATTERNS_PLOT_STYLE); + for aci in 1..=9usize { + table.aci_entries[aci].fill_style = 63 + aci as u8; + } + Ok(table) + } + "screening 100%.ctb" => Ok(Self::screening(SCREENING_100_PLOT_STYLE, 100)), + "screening 75%.ctb" => Ok(Self::screening(SCREENING_75_PLOT_STYLE, 75)), + "screening 50%.ctb" => Ok(Self::screening(SCREENING_50_PLOT_STYLE, 50)), + "screening 25%.ctb" => Ok(Self::screening(SCREENING_25_PLOT_STYLE, 25)), _ => Err(format!("Unknown built-in plot style: {name}")), } } + fn screening(name: &str, percent: u8) -> Self { + let mut table = Self::identity(name); + for entry in table.aci_entries.iter_mut().skip(1) { + entry.screening = percent; + } + table + } + /// Load one CTB by file name from the per-user plot styles folder. pub fn load_named(name: &str) -> Result { let path = Path::new(name); @@ -227,9 +283,11 @@ impl PlotStyleTable { .to_string_lossy() .eq_ignore_ascii_case(name) }) - .map(|entry| entry.path()) - .ok_or_else(|| format!("Plot style not found: {name}"))?; - return Self::load(&matched); + .map(|entry| entry.path()); + return match matched { + Some(path) => Self::load(&path), + None => Self::builtin(name), + }; } #[cfg(target_arch = "wasm32")] @@ -248,9 +306,23 @@ impl PlotStyleTable { /// Returns None if no override (use object color). pub fn resolve_color(&self, aci: u8) -> Option<[f32; 3]> { let entry = self.aci_entries.get(aci as usize)?; - entry + let mut color = entry .color - .map(|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]) + .map(|[r, g, b]| [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]); + if entry.color_policy & 2 != 0 { + let rgb = color.or_else(|| { + if aci == 7 { + Some([0.0; 3]) + } else { + acadrust::types::aci_to_rgb(aci).map(|(r, g, b)| { + [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0] + }) + } + })?; + let gray = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + color = Some([gray; 3]); + } + color } /// Resolve the effective lineweight in mm for the given ACI index. @@ -260,7 +332,13 @@ impl PlotStyleTable { if matches!(entry.lineweight, 0 | 255) { None } else { - self.lineweights.get(entry.lineweight as usize).copied() + self.lineweights.get(entry.lineweight as usize).copied().map(|weight| { + if self.apply_factor { + weight * self.scale_factor.max(0.0) + } else { + weight + } + }) } } diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 648e4162..7e312810 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -29,6 +29,11 @@ pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { )) } +#[cfg(target_arch = "wasm32")] +pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { + std::path::PathBuf::from(format!("{kind}.pdf")) +} + /// Extra options for a print job. On CUPS (Linux/macOS) these map to `lp` /// flags / `-o` options. On Windows the generated PDF already carries render /// options. Windows queues repeated jobs when more than one copy is requested; diff --git a/src/scene/layout.rs b/src/scene/layout.rs index bcf85378..ac9750a0 100644 --- a/src/scene/layout.rs +++ b/src/scene/layout.rs @@ -72,8 +72,7 @@ impl Scene { // Remove the Layout object itself. self.document.objects.remove(&layout_handle); - // Drop the layout's entry from the ACAD_LAYOUT dictionary so it does not - // dangle (and so AutoCAD doesn't try to recover a now-missing layout). + // Drop the layout's dictionary entry so it does not dangle. let dict_handle = self.document.header.acad_layout_dict_handle; if let Some(ObjectType::Dictionary(d)) = self.document.objects.get_mut(&dict_handle) { d.entries.retain(|(k, _)| k != name); @@ -90,20 +89,6 @@ impl Scene { self.document.block_records.remove(&bn); } - // Drop any standalone PlotSettings page setup tied to this layout. - let ps_handles: Vec = self - .document - .objects - .iter() - .filter_map(|(h, o)| match o { - ObjectType::PlotSettings(ps) if ps.page_name == name => Some(*h), - _ => None, - }) - .collect(); - for h in ps_handles { - self.document.objects.remove(&h); - } - // If the deleted layout was active, fall back to Model space. if self.current_layout == name { self.current_layout = "Model".to_string(); @@ -498,9 +483,7 @@ impl Scene { /// Reconstruct a `pane_grid` configuration from a set of (tile-index, rect) /// items covering `region`, by recursively finding a full vertical or -/// horizontal guillotine cut. AutoCAD tiled configs (and anything pane_grid -/// produces) are guillotine layouts, so this round-trips them. A non-guillotine -/// set falls back to chaining panes so nothing is lost. +/// horizontal guillotine cut. Non-guillotine input falls back to chained panes. fn config_from_rects( items: &[(usize, iced::Rectangle)], region: iced::Rectangle, diff --git a/src/scene/limits.rs b/src/scene/limits.rs index b6b18daa..09783637 100644 --- a/src/scene/limits.rs +++ b/src/scene/limits.rs @@ -91,6 +91,7 @@ impl Scene { self.document.header.limit_check = enabled; } else { self.document.header.paper_space_limit_check = enabled; + self.persist_current_layout_state(); } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f706fc99..313058bc 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1614,6 +1614,15 @@ pub struct Scene { /// movement scanned the whole document for wipeouts and recreated the same /// Arcs, making Paper frame construction CPU-bound on large drawings. paper_sheet_render_cache: RefCell>, + display_plot_style_cache: + RefCell>>>, + styled_wire_cache: RefCell< + HashMap<(u64, String), (u64, Arc>)>, + >, + styled_hatch_cache: + RefCell>>>, + styled_wire_fill_cache: + RefCell>>>, /// Per-viewport projected wire cache for paper-space content viewports. /// Stores projected + clipped wires in paper-space coordinates. /// Maps vp_handle → (geometry_epoch, Vec). @@ -1880,6 +1889,10 @@ impl Scene { paper_sheet_cache: RefCell::new(HashMap::default()), paper_viewport_cache: RefCell::new(HashMap::default()), paper_sheet_render_cache: RefCell::new(HashMap::default()), + display_plot_style_cache: RefCell::new(HashMap::default()), + styled_wire_cache: RefCell::new(HashMap::default()), + styled_hatch_cache: RefCell::new(HashMap::default()), + styled_wire_fill_cache: RefCell::new(HashMap::default()), paper_projected_cache: RefCell::new(HashMap::default()), current_layout: "Model".to_string(), block_edit_block: None, @@ -2878,7 +2891,9 @@ impl Scene { /// active layout or its background are dropped. pub fn set_current_layout(&mut self, name: String) { if self.current_layout != name { + self.persist_current_layout_state(); self.current_layout = name; + self.load_current_layout_state(); self.sync_active_space_to_document(); self.recolor_meshes(); *self.wire_cache.borrow_mut() = None; @@ -2888,6 +2903,91 @@ impl Scene { } } + pub fn load_current_layout_state(&mut self) { + self.display_plot_style_cache + .borrow_mut() + .retain(|(layout, _), _| layout != &self.current_layout); + self.styled_wire_cache.borrow_mut().clear(); + self.styled_hatch_cache.borrow_mut().clear(); + self.styled_wire_fill_cache.borrow_mut().clear(); + if self.current_layout == "Model" { + return; + } + if let Some((flags, insertion_base, min_extents, max_extents, min_limits, max_limits)) = + self.document.objects.values().find_map(|object| { + let ObjectType::Layout(layout) = object else { + return None; + }; + (layout.name == self.current_layout).then_some(( + layout.flags, + layout.insertion_base, + layout.min_extents, + layout.max_extents, + layout.min_limits, + layout.max_limits, + )) + }) { + self.document.header.paper_space_linetype_scaling = flags & 1 != 0; + self.document.header.paper_space_limit_check = flags & 2 != 0; + self.document.header.paper_space_insertion_base = acadrust::types::Vector3::new( + insertion_base.0, + insertion_base.1, + insertion_base.2, + ); + self.document.header.paper_space_extents_min = acadrust::types::Vector3::new( + min_extents.0, + min_extents.1, + min_extents.2, + ); + self.document.header.paper_space_extents_max = acadrust::types::Vector3::new( + max_extents.0, + max_extents.1, + max_extents.2, + ); + self.document.header.paper_space_limits_min = + acadrust::types::Vector2::new(min_limits.0, min_limits.1); + self.document.header.paper_space_limits_max = + acadrust::types::Vector2::new(max_limits.0, max_limits.1); + } + } + + pub fn persist_current_layout_state(&mut self) { + if self.current_layout == "Model" { + return; + } + let header = &self.document.header; + let psltscale = header.paper_space_linetype_scaling; + let plimcheck = header.paper_space_limit_check; + let insertion_base = header.paper_space_insertion_base; + let min_extents = header.paper_space_extents_min; + let max_extents = header.paper_space_extents_max; + let min_limits = header.paper_space_limits_min; + let max_limits = header.paper_space_limits_max; + for object in self.document.objects.values_mut() { + let ObjectType::Layout(layout) = object else { + continue; + }; + if layout.name == self.current_layout { + layout.flags = if psltscale { + layout.flags | 1 + } else { + layout.flags & !1 + }; + layout.flags = if plimcheck { + layout.flags | 2 + } else { + layout.flags & !2 + }; + layout.insertion_base = (insertion_base.x, insertion_base.y, insertion_base.z); + layout.min_extents = (min_extents.x, min_extents.y, min_extents.z); + layout.max_extents = (max_extents.x, max_extents.y, max_extents.z); + layout.min_limits = (min_limits.x, min_limits.y); + layout.max_limits = (max_limits.x, max_limits.y); + break; + } + } + } + /// Mirror the active space (Model tile-mode vs a paper layout) into the /// document's persisted settings so it round-trips on save: the `$TILEMODE` /// header (`show_model_space`) and the `CTAB` current-tab variable. The @@ -3305,32 +3405,17 @@ impl Scene { Some(wire) } - /// The effective plot settings for the current layout: a standalone - /// PlotSettings page setup if one exists, otherwise the settings embedded in - /// the LAYOUT object (paper size, margins, origin, rotation, scale). Loaded - /// The fallback preserves rotation, origin and scale from loaded files. + /// The effective plot settings embedded in the current layout. pub fn effective_plot_settings(&self) -> Option { self.plot_settings_for(&self.current_layout) } - /// Plot settings for a specific layout by name: its standalone - /// `PlotSettings` object if one exists, else synthesized from the `Layout` - /// object's embedded fields. + /// Plot settings embedded in a specific layout. pub fn plot_settings_for(&self, name: &str) -> Option { use acadrust::objects::{ ObjectType, PaperMargin, PlotPaperUnits, PlotRotation, PlotSettings, PlotType, - PlotWindow, ScaledType, + PlotWindow, ScaledType, ShadePlotMode, ShadePlotResolutionLevel, }; - if let Some(ps) = self.document.objects.values().find_map(|o| { - if let ObjectType::PlotSettings(ps) = o { - if ps.page_name.as_str() == name { - return Some(ps.clone()); - } - } - None - }) { - return Some(ps); - } self.document.objects.values().find_map(|o| { let ObjectType::Layout(l) = o else { return None; @@ -3338,10 +3423,13 @@ impl Scene { if l.name.as_str() != name { return None; } - let mut ps = PlotSettings::new(l.name.clone()); + let mut ps = PlotSettings::new(l.plot_page_name.clone()); + ps.printer_name = l.plot_printer_name.clone(); ps.paper_width = l.paper_width; ps.paper_height = l.paper_height; ps.paper_size = l.paper_size.clone(); + ps.plot_view_name = l.plot_view_name.clone(); + ps.current_style_sheet = l.plot_style_sheet.clone(); ps.margins = PaperMargin::new( l.plot_margin_left, l.plot_margin_bottom, @@ -3362,10 +3450,84 @@ impl Scene { ps.scale_type = ScaledType::from_code(l.plot_scale_type); ps.scale_numerator = l.plot_scale_numerator; ps.scale_denominator = l.plot_scale_denominator; + ps.flags = l.plot_flags; + ps.standard_scale_factor = l.plot_scale_factor; + ps.paper_image_origin_x = l.paper_image_origin_x; + ps.paper_image_origin_y = l.paper_image_origin_y; + ps.shade_plot_mode = ShadePlotMode::from_code(l.shade_plot_mode); + ps.shade_plot_resolution = + ShadePlotResolutionLevel::from_code(l.shade_plot_resolution); + ps.shade_plot_dpi = l.shade_plot_dpi; + ps.plot_view_handle = l.plot_view_handle; + ps.visual_style_handle = l.visual_style_handle; Some(ps) }) } + /// Replace the plot-settings portion embedded in one layout. + pub fn set_layout_plot_settings( + &mut self, + name: &str, + ps: &acadrust::objects::PlotSettings, + ) -> bool { + let Some(layout) = self.document.objects.values_mut().find_map(|object| { + let ObjectType::Layout(layout) = object else { + return None; + }; + (layout.name == name).then_some(layout) + }) else { + return false; + }; + + layout.plot_page_name = ps.page_name.clone(); + layout.plot_printer_name = ps.printer_name.clone(); + layout.paper_size = ps.paper_size.clone(); + layout.plot_view_name = ps.plot_view_name.clone(); + layout.plot_style_sheet = ps.current_style_sheet.clone(); + layout.plot_margin_left = ps.margins.left; + layout.plot_margin_bottom = ps.margins.bottom; + layout.plot_margin_right = ps.margins.right; + layout.plot_margin_top = ps.margins.top; + layout.paper_width = ps.paper_width; + layout.paper_height = ps.paper_height; + layout.plot_origin_x = ps.origin_x; + layout.plot_origin_y = ps.origin_y; + layout.plot_window_min_x = ps.plot_window.lower_left_x; + layout.plot_window_min_y = ps.plot_window.lower_left_y; + layout.plot_window_max_x = ps.plot_window.upper_right_x; + layout.plot_window_max_y = ps.plot_window.upper_right_y; + layout.plot_scale_numerator = ps.scale_numerator; + layout.plot_scale_denominator = ps.scale_denominator; + layout.plot_paper_units = ps.paper_units.to_code(); + layout.plot_rotation = ps.rotation.to_code(); + layout.plot_type = ps.plot_type.to_code(); + layout.plot_scale_type = ps.scale_type.to_code(); + layout.shade_plot_mode = ps.shade_plot_mode.to_code(); + layout.shade_plot_resolution = ps.shade_plot_resolution.to_code(); + layout.shade_plot_dpi = ps.shade_plot_dpi; + layout.plot_flags = ps.flags; + layout.plot_scale_factor = ps.standard_scale_factor; + layout.paper_image_origin_x = ps.paper_image_origin_x; + layout.paper_image_origin_y = ps.paper_image_origin_y; + layout.plot_view_handle = ps.plot_view_handle; + layout.visual_style_handle = ps.visual_style_handle; + layout.raw_plot_settings_codes = None; + self.display_plot_style_cache + .borrow_mut() + .retain(|(layout_name, _), _| layout_name != name); + self.styled_wire_cache.borrow_mut().clear(); + self.styled_hatch_cache.borrow_mut().clear(); + self.styled_wire_fill_cache.borrow_mut().clear(); + true + } + + pub fn invalidate_display_plot_style(&self) { + self.display_plot_style_cache.borrow_mut().clear(); + self.styled_wire_cache.borrow_mut().clear(); + self.styled_hatch_cache.borrow_mut().clear(); + self.styled_wire_fill_cache.borrow_mut().clear(); + } + /// PlotSettings store the paper size and plot margins in millimetres, but a /// layout's paper space is laid out in its own units — its viewports, /// drawing limits and the sheet the user sees can be in inches, millimetres, diff --git a/src/scene/model/hatch_model.rs b/src/scene/model/hatch_model.rs index f0f13c93..1ae480b4 100644 --- a/src/scene/model/hatch_model.rs +++ b/src/scene/model/hatch_model.rs @@ -139,6 +139,39 @@ impl GradientKind { } } +pub(crate) fn plot_style_fill_pattern(style: u8) -> Option { + let family = |angle_deg, dx, dy, dashes: &[f32]| PatFamily { + angle_deg, + x0: 0.0, + y0: 0.0, + dx, + dy, + dashes: dashes.to_vec(), + }; + let pattern = match style { + 64 => HatchPattern::Solid, + 65 => HatchPattern::Pattern(vec![ + family(0.0, 0.0, 2.0, &[2.0, -2.0]), + family(90.0, 2.0, 0.0, &[2.0, -2.0]), + ]), + 66 => HatchPattern::Pattern(vec![ + family(0.0, 0.0, 2.0, &[]), + family(90.0, 2.0, 0.0, &[]), + ]), + 67 => HatchPattern::Pattern(vec![ + family(45.0, 0.0, 2.0, &[]), + family(135.0, 0.0, 2.0, &[]), + ]), + 68 => HatchPattern::Pattern(vec![family(0.0, 0.0, 2.0, &[])]), + 69 => HatchPattern::Pattern(vec![family(135.0, 0.0, 2.0, &[])]), + 70 => HatchPattern::Pattern(vec![family(45.0, 0.0, 2.0, &[])]), + 71 => HatchPattern::Pattern(vec![family(0.0, 0.0, 2.0, &[0.0, -2.0])]), + 72 => HatchPattern::Pattern(vec![family(90.0, 2.0, 0.0, &[])]), + _ => return None, + }; + Some(pattern) +} + /// Hatch fill pattern. #[derive(Clone, Debug)] pub enum HatchPattern { diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 2e5fd703..daaa3f5a 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -22,6 +22,8 @@ use crate::scene::{ SceneLight, Uniforms, ViewportInstance, WireModel, }; +const DISPLAY_STYLE_CACHE_LIMIT: usize = if cfg!(target_arch = "wasm32") { 4 } else { 24 }; + // ── Camera hover state (shader::Program::State) ─────────────────────────── #[derive(Clone, Default)] @@ -2496,6 +2498,238 @@ impl Scene { }; (final_color, pl, pat, lw, aci) } + + fn display_plot_style(&self) -> Option> { + if self.current_layout == "Model" { + return None; + } + let settings = self.effective_plot_settings()?; + if !settings.flags.show_plot_styles || settings.current_style_sheet.is_empty() { + return None; + } + let key = ( + self.current_layout.clone(), + settings.current_style_sheet.to_ascii_lowercase(), + ); + if let Some(style) = self.display_plot_style_cache.borrow().get(&key) { + return style.clone(); + } + let style = crate::io::plot_style::PlotStyleTable::load_named( + &settings.current_style_sheet, + ) + .ok() + .map(Arc::new); + self.display_plot_style_cache + .borrow_mut() + .insert(key, style.clone()); + style + } + + fn apply_display_plot_style( + &self, + color: &mut [f32; 4], + aci: u8, + style: &crate::io::plot_style::PlotStyleTable, + ) { + if aci == 0 { + return; + } + if let Some(rgb) = style.resolve_color(aci) { + color[..3].copy_from_slice(&rgb); + } + let screening = style.resolve_screening(aci); + for (channel, paper) in color[..3] + .iter_mut() + .zip(self.paper_bg_color[..3].iter()) + { + *channel = *channel * screening + *paper * (1.0 - screening); + } + } + + fn display_styled_wires( + &self, + source: Arc>, + source_gen: u64, + ) -> (Arc>, u64) { + let Some(style) = self.display_plot_style() else { + return (source, source_gen); + }; + let key = (source_gen, style.name.to_ascii_lowercase()); + if let Some((gen, wires)) = self.styled_wire_cache.borrow().get(&key) { + return (Arc::clone(wires), *gen); + } + let mut wires = source.as_ref().clone(); + for wire in &mut wires { + self.apply_display_plot_style(&mut wire.color, wire.aci, &style); + if wire.aci > 0 { + if wire.fill_is_2d_solid + && style + .aci_entries + .get(wire.aci as usize) + .is_some_and(|entry| (65..=72).contains(&entry.fill_style)) + { + wire.fill_tris.clear(); + wire.fill_tris_low.clear(); + } + if let Some(mm) = style.resolve_lineweight(wire.aci) { + wire.line_weight_px = (mm * (96.0 / 25.4) * 2.0).max(1.0); + } + for vertex in &mut wire.text_verts { + self.apply_display_plot_style(&mut vertex.color, wire.aci, &style); + } + } + } + let wires = Arc::new(wires); + let gen = crate::scene::WIRE_CONTENT_GEN + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut cache = self.styled_wire_cache.borrow_mut(); + if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT { + cache.clear(); + } + cache.insert(key, (gen, Arc::clone(&wires))); + (wires, gen) + } + + fn display_styled_hatches( + &self, + source: Arc>, + wire_fills: Option<&Arc>>, + pattern_scale: f32, + ) -> Arc> { + let Some(style) = self.display_plot_style() else { + return source; + }; + let key = ( + self.geometry_epoch, + Arc::as_ptr(&source) as usize, + wire_fills.map_or(0, |fills| Arc::as_ptr(fills) as usize), + style.name.to_ascii_lowercase(), + pattern_scale.to_bits(), + ); + if let Some(hatches) = self.styled_hatch_cache.borrow().get(&key) { + return Arc::clone(hatches); + } + let mut hatches = source.as_ref().clone(); + for hatch in &mut hatches { + self.apply_display_plot_style(&mut hatch.color, hatch.aci, &style); + if hatch.aci > 0 { + if matches!(hatch.pattern, crate::scene::model::hatch_model::HatchPattern::Solid) { + if let Some(fill_style) = style + .aci_entries + .get(hatch.aci as usize) + .and_then(|entry| { + crate::scene::model::hatch_model::plot_style_fill_pattern( + entry.fill_style, + ) + }) + { + hatch.pattern = fill_style; + hatch.scale = pattern_scale; + } + } + if let Some(mm) = style.resolve_lineweight(hatch.aci) { + hatch.line_weight_px = (mm * (96.0 / 25.4) * 2.0).max(1.0); + } + if let crate::scene::model::hatch_model::HatchPattern::Gradient { + color2, .. + } = &mut hatch.pattern + { + self.apply_display_plot_style(color2, hatch.aci, &style); + } + } + } + if let Some(wire_fills) = wire_fills { + hatches.extend(wire_fills.iter().cloned()); + } + let hatches = Arc::new(hatches); + let mut cache = self.styled_hatch_cache.borrow_mut(); + cache.retain(|(epoch, _, _, _, _), _| *epoch == self.geometry_epoch); + if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT * 2 { + cache.clear(); + } + cache.insert(key, Arc::clone(&hatches)); + hatches + } + + fn display_styled_wire_fills( + &self, + wires: &Arc>, + source_gen: u64, + pattern_scale: f32, + ) -> Option>> { + let Some(style) = self.display_plot_style() else { + return None; + }; + let key = ( + source_gen, + style.name.to_ascii_lowercase(), + pattern_scale.to_bits(), + ); + if let Some(hatches) = self.styled_wire_fill_cache.borrow().get(&key) { + return Some(Arc::clone(hatches)); + } + let mut hatches = Vec::new(); + for wire in wires.iter().filter(|wire| wire.fill_is_2d_solid && wire.aci > 0) { + let Some(pattern) = style + .aci_entries + .get(wire.aci as usize) + .and_then(|entry| { + (65..=72) + .contains(&entry.fill_style) + .then(|| { + crate::scene::model::hatch_model::plot_style_fill_pattern( + entry.fill_style, + ) + }) + .flatten() + }) + else { + continue; + }; + let mut color = wire.color; + self.apply_display_plot_style(&mut color, wire.aci, &style); + let line_weight_px = style + .resolve_lineweight(wire.aci) + .map(|mm| (mm * (96.0 / 25.4) * 2.0).max(1.0)) + .unwrap_or(wire.line_weight_px); + for (triangle_index, triangle) in wire.fill_tris.chunks_exact(3).enumerate() { + let mut boundary = Vec::with_capacity(4); + for (point_index, point) in triangle.iter().enumerate() { + let index = triangle_index * 3 + point_index; + let low = wire.fill_tris_low.get(index).copied().unwrap_or([0.0; 3]); + boundary.push([point[0] + low[0], point[1] + low[1]]); + } + boundary.push(boundary[0]); + hatches.push(HatchModel { + render_instance: wire.render_instance.clone(), + world_origin: [0.0, 0.0], + boundary: Arc::new(boundary), + boundary_wcs: None, + fill_plane: None, + fill_plane_boundary: None, + boundary_exterior: None, + boundary_sources: None, + boundary_paths: None, + style: acadrust::entities::HatchStyleType::Normal, + pattern: pattern.clone(), + name: "PLOTSTYLE".to_string(), + color, + aci: 0, + line_weight_px, + angle_offset: 0.0, + scale: pattern_scale, + draw_depth: wire.depth_override.unwrap_or(0.0), + }); + } + } + let hatches = Arc::new(hatches); + let mut cache = self.styled_wire_fill_cache.borrow_mut(); + if cache.len() >= DISPLAY_STYLE_CACHE_LIMIT { + cache.clear(); + } + cache.insert(key, Arc::clone(&hatches)); + Some(hatches) + } } /// Whether an entity sits on a locked layer (via the document's layer table). @@ -3403,11 +3637,15 @@ impl Scene { } else { self.model_wires_for_viewport_arc(inst.handle, full.height) }; + let source_gen = self.last_model_wire_gen.get(); + let styled_fill_source = Arc::clone(&base_arc); + let (base_arc, styled_gen) = self.display_styled_wires(base_arc, source_gen); + self.last_model_wire_gen.set(styled_gen); // Wire-buffer content id for the upload gate. Preview / interim wires // are NOT part of this buffer anymore (they go in a separate per-frame // overlay buffer below), so the base id is the source's stable content // gen — a drag or camera move never re-uploads the base wire set. - let base_wire_content_id = self.last_model_wire_gen.get(); + let base_wire_content_id = styled_gen; let base_wire_patch = self.model_wire_patch_for(base_wire_content_id); // Split Face3D wires from the rest. The split is content-only (keyed // by the wire-set content id), so while the geometry is unchanged it's @@ -3548,8 +3786,15 @@ impl Scene { width: full.width.max(1.0), height: full.height.max(1.0), }; - let mut uniforms = - Uniforms::new(&inst.camera, full_bounds, self.document.header.lineweight_display); + let display_plot_lineweights = self.current_layout != "Model" + && self.effective_plot_settings().is_some_and(|settings| { + settings.flags.show_plot_styles && settings.flags.print_lineweights + }); + let mut uniforms = Uniforms::new( + &inst.camera, + full_bounds, + self.document.header.lineweight_display || display_plot_lineweights, + ); if self.document.header.paper_space_linetype_scaling && !inst.paper_sheet && inst.tile_idx.is_none() @@ -3642,6 +3887,29 @@ impl Scene { None, ) }; + let viewport_scale = if inst.paper_sheet { + 1.0 + } else { + self.document + .get_entity(inst.handle) + .and_then(|entity| match entity { + EntityType::Viewport(viewport) => Some(vp_effective_scale( + viewport.custom_scale, + viewport.view_height, + viewport.height, + )), + _ => None, + }) + .unwrap_or(1.0) + }; + let pattern_scale = (self.paper_space_unit_factor() / viewport_scale.max(1.0e-9)) as f32; + let styled_wire_fills = + self.display_styled_wire_fills(&styled_fill_source, source_gen, pattern_scale); + let hatches = self.display_styled_hatches( + hatches, + styled_wire_fills.as_ref().filter(|fills| !fills.is_empty()), + pattern_scale, + ); let images = if let Some(images) = paper_images { images } else { diff --git a/src/ui/window/plot.rs b/src/ui/window/plot.rs index db7678fc..28b5243a 100644 --- a/src/ui/window/plot.rs +++ b/src/ui/window/plot.rs @@ -63,6 +63,8 @@ pub enum PlotFlag { FitToPaper, Center, ScaleLw, + PlotStyles, + DisplayStyles, UpsideDown, Lineweights, Transparency, @@ -132,6 +134,10 @@ pub struct PlotDialogState { /// Output goes to a PDF file instead of a printer. pub to_file: bool, pub paper: String, + #[serde(skip)] + pub paper_width_mm: f64, + #[serde(skip)] + pub paper_height_mm: f64, pub orientation: String, pub upside_down: bool, pub copies: String, @@ -144,6 +150,8 @@ pub struct PlotDialogState { pub fit_to_paper: bool, #[serde(skip)] pub scales: Vec<(String, f64)>, + #[serde(skip)] + pub plot_views: Vec, pub scale_lw: bool, pub quality: String, pub shade: String, @@ -155,6 +163,8 @@ pub struct PlotDialogState { pub stamp: bool, /// Display name of the active plot style table ("" = none). pub style_name: String, + pub apply_plot_styles: bool, + pub show_plot_styles: bool, /// CTB file names discovered in the per-user plot styles folder. #[serde(skip)] pub plot_styles: Vec, @@ -185,6 +195,8 @@ impl Default for PlotDialogState { printer: None, to_file: false, paper: "A4".into(), + paper_width_mm: 297.0, + paper_height_mm: 210.0, orientation: "Landscape".into(), upside_down: false, copies: "1".into(), @@ -195,6 +207,7 @@ impl Default for PlotDialogState { scale: "1:1".into(), fit_to_paper: true, scales: Vec::new(), + plot_views: Vec::new(), scale_lw: false, quality: "Normal".into(), shade: "As displayed".into(), @@ -205,6 +218,8 @@ impl Default for PlotDialogState { paperspace_last: false, stamp: false, style_name: String::new(), + apply_plot_styles: true, + show_plot_styles: false, plot_styles: Vec::new(), style_missing: false, page_setups: Vec::new(), @@ -224,6 +239,8 @@ impl PlotDialogState { self.printer = o.printer.clone(); self.to_file = o.to_file; self.paper = o.paper.clone(); + self.paper_width_mm = o.paper_width_mm; + self.paper_height_mm = o.paper_height_mm; self.orientation = o.orientation.clone(); self.upside_down = o.upside_down; self.copies = o.copies.clone(); @@ -243,6 +260,8 @@ impl PlotDialogState { self.paperspace_last = o.paperspace_last; self.stamp = o.stamp; self.style_name = o.style_name.clone(); + self.apply_plot_styles = o.apply_plot_styles; + self.show_plot_styles = o.show_plot_styles; self.style_missing = o.style_missing; } @@ -622,7 +641,10 @@ pub fn view_window( None => PlotChoice::localized(OUT_DEFAULT), }) }; - let paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); + let mut paper_opts: Vec = PaperSize::ALL.iter().map(|p| p.label().to_string()).collect(); + if !paper_opts.iter().any(|name| name == &s.paper) { + paper_opts.push(s.paper.clone()); + } let paper_note: Element<'_, Message> = if s.area == "Layout" { text(t!("Layout plots the current sheet using the selected paper size.")) .size(10) @@ -685,11 +707,18 @@ pub fn view_window( let mut area_options = if print_all_options { choices(&["Layout"]) } else { - choices(&["Extents", "Display", "Window"]) + choices(&["Extents", "Limits", "Display", "Window"]) }; if s.paper_space && !print_all_options { area_options.insert(0, PlotChoice::localized("Layout")); } + if !print_all_options { + area_options.extend( + s.plot_views + .iter() + .map(|name| PlotChoice::raw(format!("View: {name}"))), + ); + } let mut area_row = row![ text(t!("What to plot")).size(11).style(muted_style).width(92), iced::widget::pick_list( @@ -779,6 +808,18 @@ pub fn view_window( PlotDlgMsg::Style, width, ), + check_enabled( + t!("Plot with plot styles"), + s.apply_plot_styles, + PlotFlag::PlotStyles, + !s.style_name.is_empty(), + ), + check_enabled( + t!("Display plot styles"), + s.show_plot_styles, + PlotFlag::DisplayStyles, + s.paper_space && !s.style_name.is_empty(), + ), row![ button(text(t!("Load…")).size(11)) .on_press(Message::PlotDlg(PlotDlgMsg::LoadStyle)) From 5c125f96be00dca14358cfe6aa38aeb7b14e1d5d Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:49:51 +0200 Subject: [PATCH 71/98] docs(plugin): remove superseded ocs_plugin_api/REPORT.md The out-of-process plugin design proposal in REPORT.md has been implemented and evolved to API v4. The current architecture is documented in ARCHITECTURE.md. REPORT.md is unreferenced and no longer needed; its content remains in git history for reference. --- crates/ocs_plugin_api/REPORT.md | 193 -------------------------------- 1 file changed, 193 deletions(-) delete mode 100644 crates/ocs_plugin_api/REPORT.md diff --git a/crates/ocs_plugin_api/REPORT.md b/crates/ocs_plugin_api/REPORT.md deleted file mode 100644 index 1509fd1c..00000000 --- a/crates/ocs_plugin_api/REPORT.md +++ /dev/null @@ -1,193 +0,0 @@ -# Out-of-Process Plugin Architecture — `ocs_plugin_api` - -**Status:** Design proposal -**Scope:** `crates/ocs_plugin_api` only (minimal host wiring, no plugin changes, no new crate) - ---- - -## 1. Problem - -The host currently loads external add-ons as `cdylib` libraries into its own process via `libloading`. `panic::catch_unwind` catches Rust panics, but a plugin can still corrupt host memory, segfault, or deadlock the UI thread. The fix is to run each plugin as a separate OS process and mediate all interaction through IPC. - ---- - -## 2. Design - -`ocs_plugin_api` becomes a dual-use library: - -- **Plugin side:** unchanged API surface (`BuiltinPlugin`, `HostApi`, `CadModule`, `export_plugin!`). -- **Host side:** runtime that spawns plugin processes and handles their IPC requests. - -Plugins remain `cdylib`s. The host spawns **itself** in runner mode (`--ocs-plugin-runner `) to load each cdylib in a child process and bridge to the host over `interprocess::local_socket`. The runner implementation lives inside `ocs_plugin_api` as a library module; no separate helper binary is needed, so the runner and host are always the same build and cannot get out of sync at deployment time. - ---- - -## 3. Constraints - -| Constraint | Handling | -|---|---| -| Plugin API unchanged | Trait/type signatures preserved. `document()` / `document_mut()` keep their signatures but return a local cached copy, so `API_VERSION` bumps to 3. | -| Only `ocs_plugin_api` modified | All new code lives here. The host needs only minimal call-site wiring in `src/plugin/external.rs`, `src/plugin/registry.rs`, and `src/app/plugin_host.rs`. | -| No new crate | Runner code lives inside `ocs_plugin_api`; the host executable serves as the runner process. | -| Platform-independent | `interprocess::local_socket` uses named pipes on Windows and Unix domain sockets elsewhere; self-spawning works on every host target. | - ---- - -## 4. Architecture - -```text -Host process Plugin process -┌─────────────────┐ local socket ┌─────────────────┐ -│ HostSession │◄───────────────►│ HostApi proxy │ -│ (document, UI) │ bincode frames │ (sends RPCs) │ -└─────────────────┘ └─────────────────┘ - ▲ │ - │ ▼ - PluginManager cdylib loaded - (spawn / kill / by host in - supervise) runner mode -``` - -### 4.1 IPC protocol - -All messages are length-framed and serialized with `bincode`. - -**Host → plugin:** - -- `GetManifest`, `GetRibbon` -- `Dispatch { cmd: String }` -- `InteractiveEvent { command_id, event }` -- `Shutdown` - -**Plugin → host:** - -- `PushInfo` / `PushOutput` / `PushError` -- `AddEntity(SerializedEntity)` → `Handle` -- `BumpGeometry`, `PushUndo`, `SetDirty` -- `ReadRecord`, `WriteRecord`, `RemoveRecord` -- `StartInteractive`, `PollInteractive` -- `DocumentSnapshot` → `SerializedDocument` - -`SerializedEntity`, `SerializedRecord`, and `SerializedDocument` are `acadrust` types with `Serialize` / `Deserialize` derived. - -### 4.2 Plugin-side runtime - -`OpenCADStudio --ocs-plugin-runner `: - -1. Loads the cdylib, validates `ocs_plugin_api_version`, calls `ocs_plugin_register`. -2. Connects to the host socket and answers `GetManifest` / `GetRibbon`. -3. Runs a request loop: dispatch commands, forward interactive events. - -`PluginHostApi` implements `HostApi` by sending RPCs. `document()` / `document_mut()` return a local copy fetched from `DocumentSnapshot`; mutations are **not** automatically synced back. Plugins use `add_entity`, `write_record`, etc. for host-visible changes. - -### 4.3 Host-side runtime - -`ocs_plugin_api::process` provides: - -- `PluginProcess::spawn(cdylib_path)` — creates a socket, launches the host executable in runner mode, accepts its connection. -- `PluginManager` — spawn all discovered plugins, supervise, kill. -- `serve_plugin_connection(stream, &mut dyn HostApi)` — host-side request handler. - -The host creates a `HostSession` and passes it to `serve_plugin_connection`; existing document/undo logic is reused. - -### 4.4 Ribbon - -Ribbon types use `&'static str`, which cannot cross a socket. Define owned equivalents in `ocs_plugin_api::ribbon::owned` and convert for IPC. The host reconstructs `RibbonGroup` once per load (e.g., by leaking the owned strings), avoiding changes to ribbon rendering code. - -### 4.5 Failure handling - -| Failure | Behavior | -|---|---| -| Plugin crash / hang / malformed message | Host marks plugin dead, drops its ribbon tab, logs the error, and continues running. | -| Plugin panics | Caught inside the runner; an error response is returned to the host. | -| Spawn failure | Reported through `PluginManager` and shown in the Plugin Manager. | - ---- - -## 5. Host Integration Points - -1. **`src/plugin/external.rs`** — replace `libloading`-based `LoadedPlugin` with `PluginProcess::spawn`. -2. **`src/plugin/registry.rs`** — use `PluginProcess` for ribbon collection and command dispatch. -3. **`src/app/plugin_host.rs`** — add an IPC request bridge that maps incoming messages to `HostSession` calls. - -No changes to `docs/plugin-template` or any other plugin. - ---- - -## 6. Crate Changes - -### 6.1 New files inside `crates/ocs_plugin_api` - -```text -src/ - ipc/ - protocol.rs # HostRequest / PluginRequest / PluginResponse - transport.rs # framed read/write over local_socket - client.rs # plugin-side IpcClient + PluginHostApi - server.rs # host-side serve_plugin_connection - process.rs # PluginProcess / PluginManager - runner.rs # plugin runner logic invoked by host in runner mode -``` - -### 6.2 Dependencies - -Add under the existing `host` feature: - -```toml -[dependencies] -interprocess = { version = "2", optional = true } -serde = { version = "1", features = ["derive"], optional = true } -bincode = { version = "1", optional = true } -thiserror = { version = "1", optional = true } -libloading = { version = "0.8", optional = true } - -[features] -host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"] -``` - ---- - -## 7. API Version - -Bump `API_VERSION` to `3` because `document()` / `document_mut()` semantics change from direct host references to local cached copies. v2 plugins are refused as usual; plugin authors recompile with `ApiVersion::CURRENT`. - ---- - -## 8. Implementation Plan - -1. Add dependencies to `Cargo.toml`. -2. Implement framed transport and protocol messages. -3. Implement runner logic in `runner.rs`. -4. Implement `PluginHostApi` proxy. -5. Implement host-side server and `PluginManager`. -6. Add owned ribbon conversions. -7. Wire the three host call sites and add `--ocs-plugin-runner` dispatch in `src/main.rs`. -8. Bump `API_VERSION` and update `docs/plugin-architecture.md`. - ---- - -## 9. Testing - -- **Unit:** protocol round-trip, ribbon conversion, proxy request emission. -- **Integration:** spawn a test plugin, verify dispatch and interactive command round-trip, kill the process and confirm the host survives. -- **Host:** update registry tests once `LoadedPlugin` is replaced by `PluginProcess`. - ---- - -## 10. Compliance with `AGENT.md` - -| Requirement | Status | -|---|---| -| `ocs_plugin_api` is a library, not a plugin | Yes | -| Plugin API source-compatible | Yes; signatures are unchanged. `document()` semantics change is gated by v3. | -| Separate processes + failure management | Yes | -| Platform-independent `interprocess` IPC | Yes | -| Only `ocs_plugin_api` modified | Code: yes. Host needs minimal wiring; unavoidable because `OpenCADStudio` / `HostSession` are host-private. | -| No new crate | Yes | -| Memory / process isolation | Yes | - ---- - -## 11. Summary - -`ocs_plugin_api` absorbs the plugin runtime: the host spawns itself in runner mode to load each cdylib in its own process, and all host/plugin interaction is serialized over `interprocess` local sockets. Plugin API signatures stay intact, host changes are limited to a few call sites, and no new crate is introduced. From fff811577beb3cbe5de6e52dc7bde63b18d0e2dd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:15 +0300 Subject: [PATCH 72/98] feat(text): complete single-line creation workflow --- src/app/command_driver.rs | 16 ++ src/app/commands/draw.rs | 9 +- src/app/text_inline.rs | 44 +++- src/command.rs | 8 + src/modules/annotate/text.rs | 407 +++++++++++++++++++++++++++++++++-- 5 files changed, 449 insertions(+), 35 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bd57247d..8dda1246 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3748,6 +3748,22 @@ impl OpenCADStudio { &initial, height, super::text_inline::TextEntityField::Text, + None, + ); + } + CmdResult::SuspendForTextInput { pos, entity } => { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + let height = entity.height; + self.open_text_inline( + pos, + None, + "", + height, + super::text_inline::TextEntityField::Text, + Some(entity), ); } CmdResult::EditTextEntity { handle } => { diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 065f2e62..4c2b3814 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1268,11 +1268,10 @@ impl OpenCADStudio { // ── Annotate commands ────────────────────────────────────────── "TEXT" => { use crate::modules::annotate::text::TextCommand; - let height = crate::scene::creation_style::current_text_defaults( - &self.tabs[i].scene.document, - ) - .height; - let new_cmd = TextCommand::with_height(height); + let document = &self.tabs[i].scene.document; + let defaults = crate::scene::creation_style::current_text_defaults(document); + let styles = document.text_styles.iter().cloned().collect(); + let new_cmd = TextCommand::with_defaults(defaults, styles); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index c1f8b495..b08297fc 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -95,6 +95,9 @@ pub struct TextInlineState { pub editing: Option, /// Which entity slot this session writes to on commit. pub field: TextEntityField, + /// Fully prepared entity supplied by the interactive TEXT command. Editing + /// existing text and legacy direct-open paths leave this empty. + pub creation: Option, /// Canvas-space anchor where the field is drawn (the insertion-point click). pub screen_anchor: iced::Point, } @@ -169,7 +172,7 @@ impl super::OpenCADStudio { self.open_mtext_editor(pos, Some(target), &value, height); self.unfocus_widgets() } else { - self.open_text_inline(pos, Some(target), &value, height, field); + self.open_text_inline(pos, Some(target), &value, height, field, None); iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID)) } } @@ -183,6 +186,7 @@ impl super::OpenCADStudio { initial: &str, height: f64, field: TextEntityField, + creation: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -193,6 +197,7 @@ impl super::OpenCADStudio { height: if height > 0.0 { height } else { 0.25 }, editing: handle, field, + creation, screen_anchor: iced::Point::new(60.0, 90.0), }; if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos { @@ -229,12 +234,17 @@ impl super::OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let position = plane.to_local(ed.pos); - let mut t = Text::with_value( - &ed.value, - Vector3::new(position.x, position.y, position.z), - ) - .with_height(ed.height); + let mut t = if let Some(mut prepared) = ed.creation { + prepared.value = ed.value.clone(); + prepared + } else { + let position = plane.to_local(ed.pos); + Text::with_value( + &ed.value, + Vector3::new(position.x, position.y, position.z), + ) + .with_height(ed.height) + }; // New text inherits the document's current text style (STYLE), not // the entity default. See #92. let cur_style = self.tabs[i] @@ -244,13 +254,31 @@ impl super::OpenCADStudio { .current_text_style_name .clone(); if !cur_style.is_empty() { - t.style = cur_style; + if t.style.trim().is_empty() { + t.style = cur_style; + } } let annotative = crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &t.style, ); self.push_undo_snapshot(i, "TEXT"); + self.tabs[i].scene.document.header.current_text_style_name = t.style.clone(); + let variable_height = self.tabs[i] + .scene + .document + .text_styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(&t.style)) + .is_none_or(|style| style.height <= 1.0e-9); + if variable_height + && !matches!( + t.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + ) + { + self.tabs[i].scene.document.header.text_height = t.height; + } let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); if annotative { let scale = self.tabs[i].scene.current_annotation_scale_handle(); diff --git a/src/command.rs b/src/command.rs index 46cad343..42d81896 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1443,6 +1443,14 @@ pub enum CmdResult { initial: String, height: f64, }, + /// Suspend the active TEXT command while the in-place editor collects one + /// independent line. The prepared entity carries the chosen style, + /// justification, rotation and two-point geometry. When the editor closes, + /// the command resumes so another line can be placed directly below it. + SuspendForTextInput { + pos: DVec3, + entity: acadrust::entities::Text, + }, /// Apply new pattern/scale/angle to an existing hatch entity. HatcheditApply { handle: Handle, diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index c00ad981..041394cb 100644 --- a/src/modules/annotate/text.rs +++ b/src/modules/annotate/text.rs @@ -1,7 +1,14 @@ -use crate::command::{CadCommand, CmdResult}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use acadrust::entities::{ + Text, TextHorizontalAlignment as HA, TextVerticalAlignment as VA, +}; +use acadrust::tables::TextStyle; +use acadrust::types::Vector3; use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::creation_style::TextCreationDefaults; +use crate::scene::model::wire_model::WireModel; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/text.svg")); @@ -15,57 +22,413 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, PartialEq, Eq)] enum Step { - InsertPoint, + Start, + Justification, + Style, + Height, + Rotation, + SecondPoint, } pub struct TextCommand { step: Step, + plane: WorkingPlane, + first_point: Option, + second_point: Option, + horizontal: HA, + vertical: VA, + style_name: String, + styles: Vec, height: f64, + rotation: f64, + width_factor: f64, + oblique_angle: f64, + fixed_height: bool, + annotative: bool, + generation_flags: i16, + last_entity: Option, } impl TextCommand { - pub fn with_height(height: f64) -> Self { - Self { - step: Step::InsertPoint, - height, + pub fn with_defaults(defaults: TextCreationDefaults, styles: Vec) -> Self { + let current_height = defaults.height; + let mut command = Self { + step: Step::Start, + plane: WorkingPlane::default(), + first_point: None, + second_point: None, + horizontal: HA::Left, + vertical: VA::Baseline, + style_name: defaults.style_name, + styles, + height: defaults.height, + rotation: 0.0, + width_factor: defaults.width_factor, + oblique_angle: defaults.oblique_angle, + fixed_height: false, + annotative: false, + generation_flags: 0, + last_entity: None, + }; + let style = command.style_name.clone(); + command.select_style(&style); + if !command.fixed_height { + command.height = current_height; } + command + } + + fn select_style(&mut self, name: &str) -> bool { + let Some(style) = self + .styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(name)) + .cloned() + else { + return false; + }; + self.style_name = style.name; + self.fixed_height = style.height > 1.0e-9; + if self.fixed_height { + self.height = style.height; + } else if style.last_height > 1.0e-9 { + self.height = style.last_height; + } + self.width_factor = style.width_factor.max(0.01); + self.oblique_angle = style.oblique_angle.clamp( + -85.0_f64.to_radians(), + 85.0_f64.to_radians(), + ); + self.annotative = style.annotative; + self.generation_flags = (if style.flags.backward { 2 } else { 0 }) + | (if style.flags.upside_down { 4 } else { 0 }); + true + } + + fn set_justification(&mut self, value: &str) -> bool { + let normalized = value.trim().to_ascii_uppercase().replace([' ', '-'], ""); + let alignment = match normalized.as_str() { + "L" | "LEFT" => (HA::Left, VA::Baseline), + "C" | "CENTER" => (HA::Center, VA::Baseline), + "R" | "RIGHT" => (HA::Right, VA::Baseline), + "A" | "ALIGNED" | "ALIGN" => (HA::Aligned, VA::Baseline), + "M" | "MIDDLE" => (HA::Middle, VA::Baseline), + "F" | "FIT" => (HA::Fit, VA::Baseline), + "TL" | "TOPLEFT" => (HA::Left, VA::Top), + "TC" | "TOPCENTER" => (HA::Center, VA::Top), + "TR" | "TOPRIGHT" => (HA::Right, VA::Top), + "ML" | "MIDDLELEFT" => (HA::Left, VA::Middle), + "MC" | "MIDDLECENTER" => (HA::Center, VA::Middle), + "MR" | "MIDDLERIGHT" => (HA::Right, VA::Middle), + "BL" | "BOTTOMLEFT" => (HA::Left, VA::Bottom), + "BC" | "BOTTOMCENTER" => (HA::Center, VA::Bottom), + "BR" | "BOTTOMRIGHT" => (HA::Right, VA::Bottom), + _ => return false, + }; + self.horizontal = alignment.0; + self.vertical = alignment.1; + true + } + + fn is_two_point(&self) -> bool { + matches!(self.horizontal, HA::Aligned | HA::Fit) + } + + fn after_first_point(&mut self) -> CmdResult { + if self.is_two_point() { + self.step = Step::SecondPoint; + } else if self.fixed_height { + self.step = Step::Rotation; + } else { + self.step = Step::Height; + } + CmdResult::NeedPoint + } + + fn after_second_point(&mut self) -> CmdResult { + if matches!(self.horizontal, HA::Fit) && !self.fixed_height { + self.step = Step::Height; + CmdResult::NeedPoint + } else { + self.open_editor() + } + } + + fn make_entity(&self) -> Option { + let first = self.plane.to_local(self.first_point?); + let mut text = Text::with_value("", Vector3::new(first.x, first.y, first.z)) + .with_height(self.height.max(1.0e-9)); + text.style = self.style_name.clone(); + text.width_factor = self.width_factor.max(0.01); + text.oblique_angle = self.oblique_angle; + text.rotation = self.rotation; + text.horizontal_alignment = self.horizontal; + text.vertical_alignment = self.vertical; + text.generation_flags = self.generation_flags; + text.alignment_point = if self.is_two_point() { + let second = self.plane.to_local(self.second_point?); + Some(Vector3::new(second.x, second.y, second.z)) + } else if matches!((self.horizontal, self.vertical), (HA::Left, VA::Baseline)) { + None + } else { + Some(Vector3::new(first.x, first.y, first.z)) + }; + Some(text) + } + + fn open_editor(&mut self) -> CmdResult { + let Some(entity) = self.make_entity() else { + return CmdResult::NeedPoint; + }; + let pos = self.first_point.unwrap_or(DVec3::ZERO); + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } + } + + fn open_next_line(&mut self) -> CmdResult { + let Some(mut entity) = self.last_entity.take() else { + return CmdResult::Cancel; + }; + let angle = if matches!(entity.horizontal_alignment, HA::Aligned | HA::Fit) { + entity.alignment_point.map_or(entity.rotation, |point| { + (point.y - entity.insertion_point.y) + .atan2(point.x - entity.insertion_point.x) + }) + } else { + entity.rotation + }; + let spacing = entity.height.max(1.0e-9) * 1.666_666_666_7; + let delta = Vector3::new(angle.sin() * spacing, -angle.cos() * spacing, 0.0); + entity.insertion_point = entity.insertion_point + delta; + if let Some(point) = entity.alignment_point.as_mut() { + *point = *point + delta; + } + entity.value.clear(); + let local = DVec3::new( + entity.insertion_point.x, + entity.insertion_point.y, + entity.insertion_point.z, + ); + let pos = self.plane.to_world(local); + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } } } impl CadCommand for TextCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn name(&self) -> &'static str { "TEXT" } fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("TEXT Specify insertion point:").into_owned(), + match self.step { + Step::Start => format!( + "{}\n{}", + crate::tf!( + "TEXT Current style: {}, Height: {}, Annotative: {}", + self.style_name, + self.height, + if self.annotative { "Yes" } else { "No" } + ), + t!("TEXT Specify start point or [Justify/Style]:") + ), + Step::Justification => t!( + "TEXT Enter justification [Left/Center/Right/Aligned/Middle/Fit/TL/TC/TR/ML/MC/MR/BL/BC/BR]:" + ) + .into_owned(), + Step::Style => crate::tf!("TEXT Enter style name <{}>:", self.style_name).into_owned(), + Step::Height if self.annotative => { + crate::tf!("TEXT Specify paper text height <{}>:", self.height).into_owned() + } + Step::Height => crate::tf!("TEXT Specify height <{}>:", self.height).into_owned(), + Step::Rotation => crate::tf!( + "TEXT Specify rotation angle <{}>:", + self.rotation.to_degrees() + ) + .into_owned(), + Step::SecondPoint => t!("TEXT Specify second endpoint:").into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place plain-text editor anchored at the click. - CmdResult::OpenTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::Start => vec![ + CmdOption::new(t!("Justify").as_ref(), "J"), + CmdOption::new(t!("Style").as_ref(), "ST"), + ], + Step::Justification => [ + ("Left", "L"), ("Center", "C"), ("Right", "R"), + ("Aligned", "A"), ("Middle", "M"), ("Fit", "F"), + ("TL", "TL"), ("TC", "TC"), ("TR", "TR"), + ("ML", "ML"), ("MC", "MC"), ("MR", "MR"), + ("BL", "BL"), ("BC", "BC"), ("BR", "BR"), + ] + .into_iter() + .map(|(label, keyword)| CmdOption::new(label, keyword)) + .collect(), + Step::Style => self + .styles + .iter() + .map(|style| CmdOption::new(&style.name, &style.name)) + .collect(), + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + matches!( + self.step, + Step::Justification | Step::Style | Step::Height | Step::Rotation + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Start) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim(); + let upper = token.to_ascii_uppercase(); + match self.step { + Step::Start => match upper.as_str() { + "J" | "JUSTIFY" | "JUSTIFICATION" => self.step = Step::Justification, + "S" | "ST" | "STYLE" => self.step = Step::Style, + _ => return None, + }, + Step::Justification => { + if !self.set_justification(token) { + return None; + } + self.step = Step::Start; + } + Step::Style => { + if !self.select_style(token) { + return None; + } + self.step = Step::Start; + } + Step::Height => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() || value <= 1.0e-9 { + return None; + } + self.height = value; + if self.is_two_point() { + return Some(self.open_editor()); + } + self.step = Step::Rotation; + } + Step::Rotation => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() { + return None; + } + self.rotation = value.to_radians(); + return Some(self.open_editor()); + } + Step::SecondPoint => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Start => { + self.first_point = Some(point); + self.second_point = None; + self.after_first_point() + } + Step::Height => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let value = self + .plane + .vector_to_local(point - first) + .truncate() + .length(); + if value <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.height = value; + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let Some(angle) = self.plane.angle(first, point) else { + return CmdResult::NeedPoint; + }; + self.rotation = angle; + self.open_editor() + } + Step::SecondPoint => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + if self + .plane + .vector_to_local(point - first) + .truncate() + .length() + <= 1.0e-9 + { + return CmdResult::NeedPoint; + } + self.second_point = Some(point); + self.after_second_point() + } + Step::Justification | Step::Style => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Start => CmdResult::Cancel, + Step::Justification | Step::Style => { + self.step = Step::Start; + CmdResult::NeedPoint + } + Step::Height => { + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => self.open_editor(), + Step::SecondPoint => CmdResult::NeedPoint, + } } + + fn on_editor_closed(&mut self, committed: bool) -> CmdResult { + if committed { + self.open_next_line() + } else { + CmdResult::Cancel + } + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); // TextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); From 3e9fcaed7c935b33a474a8902567688a79dc3afd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:21 +0300 Subject: [PATCH 73/98] fix(properties): align single-line text fields --- src/app/properties.rs | 54 +++++++++++++++++++++++++++++++++++ src/scene/cache/properties.rs | 4 ++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index bd7daa3c..43d3d463 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1799,6 +1799,60 @@ impl OpenCADStudio { } } + // Single-line text height rows depend on both the + // justification and the active annotation scale. Aligned + // text derives its paper height from the two endpoints, + // while annotative text exposes that paper height and a + // separate calculated model height. + if let acadrust::EntityType::Text(text) = entity { + let aligned = matches!( + text.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + ); + let paper_height = if aligned { + crate::entities::text::text_run_placement(text, doc).height as f64 + } else { + text.height + }; + let annotative = crate::scene::annotative::is_annotative(doc, entity); + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|row| row.field == "height") + { + if annotative { + row.label = t!("Paper text height").into_owned(); + } + if aligned { + row.value = crate::scene::model::object::PropValue::ReadOnly( + crate::entities::common::format_length(paper_height), + ); + } + } + } + if annotative { + let model_factor = annotation_scale_handle + .and_then(|handle| match doc.objects.get(&handle) { + Some(acadrust::objects::ObjectType::Scale(scale)) => Some( + scale.inverse_factor() + / self.tabs[i].scene.annotation_scale_unit_factor(), + ), + _ => None, + }) + .unwrap_or(self.tabs[i].scene.annotation_scale as f64); + insert_row_after( + &mut sections, + "height", + crate::entities::common::ro_prop( + t!("Model text height").as_ref(), + "model_text_height", + crate::entities::common::format_length( + paper_height * model_factor, + ), + ), + ); + } + } + if !group_names.is_empty() { let label = group_names.join(", "); if let Some(general) = sections.first_mut() { diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index fe199e88..67078b2c 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) { + if matches!(entity, EntityType::Text(_)) + || matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) + { section.props.retain(|prop| prop.field != "handle"); } From c00d08d40af99ccb876160f905b3996e4844f31d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:50:31 +0300 Subject: [PATCH 74/98] fix(text): honor two-point geometry and grips --- src/app/update/command.rs | 7 +- src/entities/text.rs | 160 ++++++++++++++++++++++++++++++-------- 2 files changed, 134 insertions(+), 33 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index d6c48ab6..8b8e2ea0 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1162,6 +1162,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if matches!( item.action, GripMenuAction::Stretch + | GripMenuAction::MoveWithText | GripMenuAction::MoveWithDimLine | GripMenuAction::MoveWithLeader | GripMenuAction::MoveIndependent @@ -1212,7 +1213,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { - (popup.grip_id, if is_dimension { false } else { g.is_midpoint }) + ( + popup.grip_id, + matches!(item.action, GripMenuAction::MoveWithText) + || (!is_dimension && g.is_midpoint), + ) }; self.tabs[i].active_grip = Some(GripEdit::single( popup.handle, diff --git a/src/entities/text.rs b/src/entities/text.rs index 18a96fda..fd0b69ea 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -43,7 +43,24 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { (HA::Left, VA::Baseline) ); if needs_alignment_point { - if t.alignment_point.is_none() { + if matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) { + let point = t.alignment_point.unwrap_or(t.insertion_point); + let dx = point.x - t.insertion_point.x; + let dy = point.y - t.insertion_point.y; + if dx.hypot(dy) <= 1.0e-9 { + let span = t.height.max(1.0e-6) + * t.width_factor.abs().max(0.01) + * t.value.chars().count().max(1) as f64 + * 0.6; + t.alignment_point = Some(acadrust::types::Vector3::new( + t.insertion_point.x + t.rotation.cos() * span, + t.insertion_point.y + t.rotation.sin() * span, + t.insertion_point.z, + )); + } else { + t.alignment_point = Some(point); + } + } else if t.alignment_point.is_none() { t.alignment_point = Some(t.insertion_point); } } else { @@ -51,6 +68,21 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { } } +fn two_point_span(t: &Text) -> Option<(f64, f64)> { + if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) { + return None; + } + let point = t.alignment_point?; + let dx = point.x - t.insertion_point.x; + let dy = point.y - t.insertion_point.y; + let distance = dx.hypot(dy); + (distance > 1.0e-9).then(|| (distance, dy.atan2(dx))) +} + +fn displayed_rotation(t: &Text) -> f64 { + two_point_span(t).map_or(t.rotation, |(_, angle)| angle) +} + /// Resolved placement of a TEXT run: the baseline-anchored run origin (WCS xy) /// plus every parameter needed to lay the glyphs out. Shared by `to_render` (the /// stroke path) and the SDF-quad text collector so both place text identically. @@ -152,10 +184,9 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla ); let resolved_style = resolve_text_style(&t.style, document); let font_name = resolved_style.font_name; - // AutoCAD text geometry rule: the entity stores the FINAL width factor / - // oblique angle, copied from the style at creation and persisting through - // style edits. Use it as-is. Only fall back to the style when the entity - // value is missing (the parser reports 0.0 for default-omitted fields). + // The entity stores the final width factor and oblique angle copied from + // its style at creation. Only fall back to the style when an omitted field + // was read as zero. let base_wf = if t.width_factor.abs() > 1e-9 { (t.width_factor as f32).clamp(0.01, 100.0) } else { @@ -168,17 +199,43 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla // mirror, and XOR keeps a double mirror an involution. let eff_backward = resolved_style.is_backward ^ (t.generation_flags & 0x2 != 0); let eff_upside = resolved_style.is_upside_down ^ (t.generation_flags & 0x4 != 0); - let width_factor = if eff_backward { -base_wf } else { base_wf }; - let rotation = if eff_upside { - t.rotation as f32 + std::f32::consts::PI - } else { - t.rotation as f32 - }; + let mut width_factor = if eff_backward { -base_wf } else { base_wf }; let oblique_angle = if t.oblique_angle.abs() > 1e-9 { t.oblique_angle as f32 } else { resolved_style.oblique_angle }; + let value_for_bounds = resolve_dxf_special_chars(&t.value); + let mut height = t.height.max(1.0e-9) as f32; + let mut base_rotation = t.rotation as f32; + + // Aligned and Fit are true two-point modes. Both derive their baseline + // direction from the endpoints. Aligned scales height uniformly; Fit keeps + // the height and changes only the horizontal factor. + if let Some((span, angle)) = two_point_span(t) { + base_rotation = angle as f32; + if let Some(base_bounds) = text_local_bounds( + &font_name, + &value_for_bounds, + height, + width_factor, + oblique_angle, + ) { + if base_bounds.advance > 1.0e-6 { + let scale = (span as f32 / base_bounds.advance).max(1.0e-6); + if matches!(t.horizontal_alignment, HA::Aligned) { + height *= scale; + } else { + width_factor *= scale; + } + } + } + } + let rotation = if eff_upside { + base_rotation + std::f32::consts::PI + } else { + base_rotation + }; // Anchor stays f64: large coordinates (UTM etc.) lose ~0.5 units of // precision when cast to f32, which snaps text baselines onto a coarse // grid and makes adjacent rows collide. Only the small local offsets @@ -188,17 +245,16 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla &t.vertical_alignment, &t.alignment_point, ) { - (HA::Aligned | HA::Middle | HA::Fit, _, Some(a)) => [a.x, a.y], + (HA::Aligned | HA::Fit, _, _) => [t.insertion_point.x, t.insertion_point.y], + (HA::Middle, _, Some(a)) => [a.x, a.y], (HA::Center | HA::Right, _, Some(a)) => [a.x, a.y], (_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x, a.y], _ => [t.insertion_point.x, t.insertion_point.y], }; - // Strip %%u/%%o for bounds (they add no width); resolve %%d/%%c/%%p for correct advance. - let value_for_bounds = resolve_dxf_special_chars(&t.value); let bounds = text_local_bounds( &font_name, &value_for_bounds, - t.height as f32, + height, width_factor, oblique_angle, ); @@ -213,7 +269,8 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla let ax = match t.horizontal_alignment { HA::Left => 0.0, HA::Center | HA::Middle => b.advance * 0.5 * sign, - HA::Right | HA::Aligned | HA::Fit => b.advance * sign, + HA::Right => b.advance * sign, + HA::Aligned | HA::Fit => 0.0, }; // Vertical anchor uses the inked extent (cap / baseline geometry). let ay = match t.vertical_alignment { @@ -236,7 +293,7 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla ]; TextPlacement { origin, - height: t.height as f32, + height, rotation, width_factor, oblique_angle, @@ -247,12 +304,19 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla } fn grips(t: &Text) -> Vec { - let p = glam::DVec3::new( + let insertion = glam::DVec3::new( t.insertion_point.x, t.insertion_point.y, t.insertion_point.z, ); - vec![square_grip(0, p)] + let mut grips = vec![square_grip(0, insertion)]; + if let Some(point) = t.alignment_point { + grips.push(square_grip( + 1, + glam::DVec3::new(point.x, point.y, point.z), + )); + } + grips } fn properties(t: &Text, text_style_names: &[String]) -> Vec { @@ -263,6 +327,8 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { // both points are live. let is_plain_left = matches!(t.horizontal_alignment, HA::Left) && matches!(t.vertical_alignment, VA::Baseline); + let is_aligned = matches!(t.horizontal_alignment, HA::Aligned); + let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); let pos_editable = is_plain_left || matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); let align_editable = !is_plain_left; // The alignment point is meaningless (reset to the origin) for plain-Left text. @@ -331,9 +397,18 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { t!("Height").as_ref(), "height", t.height, - crate::entities::common::style_fixed_height(&t.style).is_none(), + !is_aligned + && crate::entities::common::style_fixed_height(&t.style).is_none(), ), - edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()), + if is_two_point { + ro( + t!("Rotation").as_ref(), + "rotation", + crate::entities::common::format_angle(displayed_rotation(t)), + ) + } else { + edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()) + }, edit(t!("Width factor").as_ref(), "width_factor", t.width_factor), edit_angle(t!("Obliquing").as_ref(), "oblique_angle", t.oblique_angle.to_degrees()), num_row(t!("Text alignment X").as_ref(), "align_x", ax, align_editable), @@ -451,25 +526,44 @@ fn apply_geom_prop(t: &mut Text, field: &str, value: &str) { _ => ap.z = v, } } - "height" if v > 0.0 => t.height = v, - "rotation" => t.rotation = v.to_radians(), + "height" + if v > 0.0 && !matches!(t.horizontal_alignment, HA::Aligned) => + { + t.height = v + } + "rotation" if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) => { + t.rotation = v.to_radians() + } "width_factor" if v > 0.0 => t.width_factor = v, - "oblique_angle" => t.oblique_angle = v.to_radians(), + "oblique_angle" if (-85.0..=85.0).contains(&v) => { + t.oblique_angle = v.to_radians() + } _ => {} } } -fn apply_grip(t: &mut Text, _grip_id: usize, apply: GripApply) { +fn apply_grip(t: &mut Text, grip_id: usize, apply: GripApply) { match apply { GripApply::Absolute(p) => { - t.insertion_point.x = p.x as f64; - t.insertion_point.y = p.y as f64; - t.insertion_point.z = p.z as f64; + let target = if grip_id == 1 { + let insertion = t.insertion_point; + t.alignment_point.get_or_insert(insertion) + } else { + &mut t.insertion_point + }; + target.x = p.x; + target.y = p.y; + target.z = p.z; } GripApply::Translate(d) => { - t.insertion_point.x += d.x as f64; - t.insertion_point.y += d.y as f64; - t.insertion_point.z += d.z as f64; + t.insertion_point.x += d.x; + t.insertion_point.y += d.y; + t.insertion_point.z += d.z; + if let Some(point) = t.alignment_point.as_mut() { + point.x += d.x; + point.y += d.y; + point.z += d.z; + } } } } @@ -551,7 +645,9 @@ impl Grippable for Text { value: f64, ) { use crate::scene::model::object::GripMenuAction as A; - if matches!(action, A::RotateText) { + if matches!(action, A::RotateText) + && !matches!(self.horizontal_alignment, HA::Aligned | HA::Fit) + { self.rotation = value.to_radians(); } } From 0f5f47b37d5dad1b603cc88dc69251799ec604f5 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:05:07 +0300 Subject: [PATCH 75/98] fix(properties): correct text position editability --- src/entities/text.rs | 54 ++++++++++++++++++++++++++++---------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/src/entities/text.rs b/src/entities/text.rs index fd0b69ea..48e1ca0e 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -329,15 +329,23 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { && matches!(t.vertical_alignment, VA::Baseline); let is_aligned = matches!(t.horizontal_alignment, HA::Aligned); let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); - let pos_editable = is_plain_left || matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); - let align_editable = !is_plain_left; - // The alignment point is meaningless (reset to the origin) for plain-Left text. + // The visible Properties palette exposes Text alignment as calculated + // coordinates and Position as the editable placement point. For aligned + // and fit text Position targets the first endpoint; for every other + // non-left mode it targets the alignment anchor that actually places the + // glyph run. + let position_uses_alignment = !is_plain_left && !is_two_point; let ap = t.alignment_point.unwrap_or(t.insertion_point); - let (ax, ay, az) = if align_editable { + let (ax, ay, az) = if !is_plain_left { (ap.x, ap.y, ap.z) } else { (0.0, 0.0, 0.0) }; + let position = if position_uses_alignment { + ap + } else { + t.insertion_point + }; vec![ PropSection { title: t!("Text").into_owned(), @@ -411,17 +419,17 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { }, edit(t!("Width factor").as_ref(), "width_factor", t.width_factor), edit_angle(t!("Obliquing").as_ref(), "oblique_angle", t.oblique_angle.to_degrees()), - num_row(t!("Text alignment X").as_ref(), "align_x", ax, align_editable), - num_row(t!("Text alignment Y").as_ref(), "align_y", ay, align_editable), - num_row(t!("Text alignment Z").as_ref(), "align_z", az, align_editable), + num_row(t!("Text alignment X").as_ref(), "align_x", ax, false), + num_row(t!("Text alignment Y").as_ref(), "align_y", ay, false), + num_row(t!("Text alignment Z").as_ref(), "align_z", az, false), ], }, PropSection { title: t!("Geometry").into_owned(), props: vec![ - num_row(t!("Position X").as_ref(), "ins_x", t.insertion_point.x, pos_editable), - num_row(t!("Position Y").as_ref(), "ins_y", t.insertion_point.y, pos_editable), - num_row(t!("Position Z").as_ref(), "ins_z", t.insertion_point.z, pos_editable), + num_row(t!("Position X").as_ref(), "ins_x", position.x, true), + num_row(t!("Position Y").as_ref(), "ins_y", position.y, true), + num_row(t!("Position Z").as_ref(), "ins_z", position.z, true), ], }, PropSection { @@ -514,18 +522,26 @@ fn apply_geom_prop(t: &mut Text, field: &str, value: &str) { return; }; match field { - "ins_x" => t.insertion_point.x = v, - "ins_y" => t.insertion_point.y = v, - "ins_z" => t.insertion_point.z = v, - "align_x" | "align_y" | "align_z" => { - let ins = t.insertion_point; - let ap = t.alignment_point.get_or_insert(ins); + "ins_x" | "ins_y" | "ins_z" => { + let plain_left = matches!(t.horizontal_alignment, HA::Left) + && matches!(t.vertical_alignment, VA::Baseline); + let two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); + let target = if !plain_left && !two_point { + let insertion = t.insertion_point; + t.alignment_point.get_or_insert(insertion) + } else { + &mut t.insertion_point + }; match field { - "align_x" => ap.x = v, - "align_y" => ap.y = v, - _ => ap.z = v, + "ins_x" => target.x = v, + "ins_y" => target.y = v, + _ => target.z = v, } } + "align_x" | "align_y" | "align_z" => { + // Calculated display rows are intentionally not writable. + return; + } "height" if v > 0.0 && !matches!(t.horizontal_alignment, HA::Aligned) => { From 4be01774e17f49933e7d8f48e11952b1c0abd4fd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:21 +0300 Subject: [PATCH 76/98] Complete MText boundary creation flow --- src/app/command_driver.rs | 5 +- src/app/commands/draw.rs | 8 +- src/app/text_inline.rs | 2 +- src/command.rs | 5 + src/modules/annotate/mtext.rs | 334 ++++++++++++++++++++++++++++++++-- 5 files changed, 330 insertions(+), 24 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bd57247d..ea345814 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3716,10 +3716,11 @@ impl OpenCADStudio { handle, initial, height, + template, } => { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.open_mtext_editor(pos, handle, &initial, height); + self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m)); } CmdResult::SuspendForMTextInput { pos, @@ -3732,7 +3733,7 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); self.command_mtext_input = true; self.pending_command_editor_text = None; - self.open_mtext_editor(pos, None, &initial, height); + self.open_mtext_editor(pos, None, &initial, height, None); } CmdResult::OpenTextEditor { pos, diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 065f2e62..1fab3abf 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1307,7 +1307,13 @@ impl OpenCADStudio { &self.tabs[i].scene.document, ) .height; - let new_cmd = MTextCommand::with_height(height); + let style = self.tabs[i] + .scene + .document + .header + .current_text_style_name + .clone(); + let new_cmd = MTextCommand::with_defaults(height, style); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index c1f8b495..5a110088 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -166,7 +166,7 @@ impl super::OpenCADStudio { }; if field.is_rich() { - self.open_mtext_editor(pos, Some(target), &value, height); + self.open_mtext_editor(pos, Some(target), &value, height, None); self.unfocus_widgets() } else { self.open_text_inline(pos, Some(target), &value, height, field); diff --git a/src/command.rs b/src/command.rs index 46cad343..50d17df8 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1427,6 +1427,11 @@ pub enum CmdResult { handle: Option, initial: String, height: f64, + /// Optional entity defaults collected by the interactive MTEXT + /// command (boundary, rotation, attachment, spacing and columns). + /// Existing-entity edits leave this as `None` and load the document + /// entity instead. + template: Option>, }, /// Collect rich text without creating an MText entity. SuspendForMTextInput { diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index a6a6d6d6..5a1f838a 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -1,7 +1,11 @@ -use crate::command::{CadCommand, CmdResult}; +use acadrust::entities::mtext::AttachmentPoint; +use acadrust::types::Vector3; +use acadrust::MText; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::DVec3; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mtext.svg")); @@ -15,20 +19,136 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Step { - InsertPoint, + FirstCorner, + OppositeCorner, + Height, + Justify, + LineSpacing, + Rotation, + Style, + Width, + ColumnMode, + ColumnCount, + ColumnWidth, + ColumnGutter, } pub struct MTextCommand { step: Step, + first: Option, + plane: WorkingPlane, height: f64, + style: String, + attachment: AttachmentPoint, + line_spacing: f64, + rotation: f64, + width: Option, + column_type: i16, + column_count: i32, + column_width: f64, + column_gutter: f64, } impl MTextCommand { - pub fn with_height(height: f64) -> Self { + pub fn with_defaults(height: f64, style: String) -> Self { Self { - step: Step::InsertPoint, - height, + step: Step::FirstCorner, + first: None, + plane: WorkingPlane::default(), + height: height.max(1e-6), + style, + attachment: AttachmentPoint::TopLeft, + line_spacing: 1.0, + rotation: 0.0, + width: None, + column_type: 0, + column_count: 2, + column_width: 0.0, + column_gutter: 0.0, + } + } + + fn resume_point_step(&mut self) { + self.step = if self.first.is_some() { + Step::OppositeCorner + } else { + Step::FirstCorner + }; + } + + fn point_options() -> Vec { + vec![ + CmdOption::new("Height", "HEIGHT"), + CmdOption::new("Justify", "JUSTIFY"), + CmdOption::new("Line spacing", "LINESPACING"), + CmdOption::new("Rotation", "ROTATION"), + CmdOption::new("Style", "STYLE"), + CmdOption::new("Width", "WIDTH"), + CmdOption::new("Columns", "COLUMNS"), + ] + } + + fn begin_option(&mut self, keyword: &str) -> bool { + self.step = match keyword { + "H" | "HEIGHT" => Step::Height, + "J" | "JUSTIFY" => Step::Justify, + "L" | "LINESPACING" | "LINE SPACING" => Step::LineSpacing, + "R" | "ROTATION" => Step::Rotation, + "S" | "STYLE" => Step::Style, + "W" | "WIDTH" => Step::Width, + "C" | "COLUMNS" => Step::ColumnMode, + _ => return false, + }; + true + } + + fn boundary_size(&self, opposite: DVec3) -> (f64, f64) { + let first = self.first.unwrap_or(opposite); + let local = self.plane.vector_to_local(opposite - first); + let (sin, cos) = self.rotation.sin_cos(); + let horizontal = local.x * cos + local.y * sin; + let vertical = -local.x * sin + local.y * cos; + (self.width.unwrap_or(horizontal.abs()), vertical.abs()) + } + + fn open_editor(&self, opposite: DVec3) -> CmdResult { + let first = self.first.unwrap_or(opposite); + let (width, boundary_height) = self.boundary_size(opposite); + let mut template = MText::default(); + template.insertion_point = Vector3::new(first.x, first.y, first.z); + template.normal = Vector3::new(self.plane.z.x, self.plane.z.y, self.plane.z.z); + template.height = self.height; + template.rectangle_width = width.max(0.0); + template.rectangle_height = (boundary_height > 1e-9).then_some(boundary_height); + template.rotation = self.rotation; + template.style = self.style.clone(); + template.attachment_point = self.attachment; + template.line_spacing_factor = self.line_spacing; + template.column_data.column_type = self.column_type; + if self.column_type != 0 { + template.column_data.column_count = self.column_count.max(1); + template.column_data.auto_height = self.column_type == 2; + template.column_data.width = if self.column_width > 0.0 { + self.column_width + } else if width > 0.0 { + width + } else { + self.height * 10.0 + }; + template.column_data.gutter = if self.column_gutter > 0.0 { + self.column_gutter + } else { + self.height + }; + } + CmdResult::OpenMTextEditor { + pos: first, + handle: None, + initial: String::new(), + height: self.height, + template: Some(Box::new(template)), } } } @@ -38,34 +158,208 @@ impl CadCommand for MTextCommand { "MTEXT" } + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("MTEXT Specify insertion point:").into_owned(), + match self.step { + Step::FirstCorner => t!("MTEXT Specify first corner:").into_owned(), + Step::OppositeCorner => t!("MTEXT Specify opposite corner:").into_owned(), + Step::Height => crate::tf!("MTEXT Specify text height <{}>:", self.height).into_owned(), + Step::Justify => t!("MTEXT Enter justification [TL / TC / TR / ML / MC / MR / BL / BC / BR] :").into_owned(), + Step::LineSpacing => crate::tf!("MTEXT Enter line spacing factor (0.25-4.00) <{}>:", self.line_spacing).into_owned(), + Step::Rotation => crate::tf!("MTEXT Specify rotation angle <{}>:", self.rotation.to_degrees()).into_owned(), + Step::Style => crate::tf!("MTEXT Enter text style <{}>:", self.style).into_owned(), + Step::Width => crate::tf!("MTEXT Specify boundary width <{}>:", self.width.unwrap_or(0.0)).into_owned(), + Step::ColumnMode => t!("MTEXT Columns [None / Static / Dynamic] :").into_owned(), + Step::ColumnCount => crate::tf!("MTEXT Enter column count <{}>:", self.column_count).into_owned(), + Step::ColumnWidth => crate::tf!("MTEXT Enter column width <{}>:", self.column_width).into_owned(), + Step::ColumnGutter => crate::tf!("MTEXT Enter column gutter <{}>:", self.column_gutter).into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place editor (toolbar + text area + live preview). - CmdResult::OpenMTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::FirstCorner | Step::OppositeCorner => Self::point_options(), + Step::Justify => ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"] + .into_iter() + .map(|value| CmdOption::new(value, value)) + .collect(), + Step::ColumnMode => vec![ + CmdOption::new("None", "NONE"), + CmdOption::new("Static", "STATIC"), + CmdOption::new("Dynamic", "DYNAMIC"), + ], + _ => Vec::new(), + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + matches!(self.step, Step::Style) + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Rotation => DynField::Angle, + Step::Height + | Step::LineSpacing + | Step::Width + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => DynField::Scalar, + _ => DynField::Point, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let trimmed = text.trim(); + let upper = trimmed.to_ascii_uppercase(); + if matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + && self.begin_option(&upper) + { + return Some(CmdResult::NeedPoint); + } + + match self.step { + Step::Height => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.height = value; + self.resume_point_step(); + } + Step::Justify => { + self.attachment = match upper.as_str() { + "TL" => AttachmentPoint::TopLeft, + "TC" => AttachmentPoint::TopCenter, + "TR" => AttachmentPoint::TopRight, + "ML" => AttachmentPoint::MiddleLeft, + "MC" => AttachmentPoint::MiddleCenter, + "MR" => AttachmentPoint::MiddleRight, + "BL" => AttachmentPoint::BottomLeft, + "BC" => AttachmentPoint::BottomCenter, + "BR" => AttachmentPoint::BottomRight, + _ => return None, + }; + self.resume_point_step(); + } + Step::LineSpacing => { + let value = trimmed.replace(',', ".").parse::().ok()?; + if !(0.25..=4.0).contains(&value) { + return None; + } + self.line_spacing = value; + self.resume_point_step(); + } + Step::Rotation => { + self.rotation = crate::entities::common::parse_typed_direction(trimmed)?; + self.resume_point_step(); + } + Step::Style => { + if trimmed.is_empty() { + return None; + } + self.style = trimmed.to_string(); + self.resume_point_step(); + } + Step::Width => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.width = Some(value); + self.resume_point_step(); + } + Step::ColumnMode => { + self.column_type = match upper.as_str() { + "N" | "NONE" => 0, + "S" | "STATIC" => 1, + "D" | "DYNAMIC" => 2, + _ => return None, + }; + if self.column_type == 0 { + self.resume_point_step(); + } else { + self.step = Step::ColumnCount; + } + } + Step::ColumnCount => { + let value = trimmed.parse::().ok()?; + if value < 1 { + return None; + } + self.column_count = value; + self.step = Step::ColumnWidth; + } + Step::ColumnWidth => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.column_width = value; + self.step = Step::ColumnGutter; + } + Step::ColumnGutter => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.column_gutter = value; + self.resume_point_step(); + } + Step::FirstCorner | Step::OppositeCorner => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::FirstCorner => { + self.first = Some(point); + self.step = Step::OppositeCorner; + CmdResult::NeedPoint + } + Step::OppositeCorner => self.open_editor(point), + _ => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Height + | Step::Justify + | Step::LineSpacing + | Step::Rotation + | Step::Style + | Step::Width + | Step::ColumnMode + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => { + self.resume_point_step(); + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); From 96ca741f3587339d1880ef2699ce36e2aea871ec Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:22 +0300 Subject: [PATCH 77/98] Align MText properties and column layout --- src/entities/attribute.rs | 3 + src/entities/mtext.rs | 140 +++++++++++++++++++++++++++------- src/entities/multileader.rs | 16 ++++ src/entities/table.rs | 6 ++ src/entities/text_support.rs | 79 +++++++++++++++++-- src/scene/cache/properties.rs | 4 +- 6 files changed, 214 insertions(+), 34 deletions(-) diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index dd5cc539..47991b80 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -200,6 +200,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument oblique_angle, is_backward: width_factor < 0.0, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { // Not an MTEXT: text in a fixed box, never columnar. @@ -213,6 +214,8 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 5c6a46eb..3dcee115 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -28,6 +28,9 @@ fn columns_of(t: &MText) -> MTextColumns { count: c.column_count.max(0) as usize, width: c.width as f32, gutter: c.gutter as f32, + flow_reversed: c.flow_reversed, + auto_height: c.auto_height, + heights: c.heights.iter().map(|height| *height as f32).collect(), } } @@ -120,7 +123,14 @@ pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec attach_h_anchor, v_anchor, line_spacing_factor: t.line_spacing_factor as f32, - vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + exact_line_spacing: matches!( + t.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) + || matches!(t.drawing_direction, DrawingDirection::ByStyle) + && resolved_style.is_vertical, want_glyph_boxes: true, columns: columns_of(t), }); @@ -169,7 +179,14 @@ fn to_render(t: &MText, document: &acadrust::CadDocument) -> RenderEntity { attach_h_anchor, v_anchor, line_spacing_factor: t.line_spacing_factor as f32, - vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + exact_line_spacing: matches!( + t.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) + || matches!(t.drawing_direction, DrawingDirection::ByStyle) + && resolved_style.is_vertical, want_glyph_boxes: false, columns: columns_of(t), }); @@ -231,8 +248,43 @@ fn grips(t: &MText) -> Vec { t.insertion_point.z, ); let (dir, k) = width_grip_axis(t); - let width_grip = p + dir * (k * t.rectangle_width.max(0.0)); - vec![square_grip(0, p), triangle_grip(1, width_grip)] + let columns_active = t.column_data.column_type != 0 + && t.column_data.column_count > 1 + && t.column_data.width > 0.0; + let block_width = if columns_active { + t.column_data.width * t.column_data.column_count as f64 + + t.column_data.gutter * (t.column_data.column_count - 1) as f64 + } else { + t.rectangle_width.max(0.0) + }; + let width_grip = p + dir * (k * block_width); + let mut result = vec![square_grip(0, p), triangle_grip(1, width_grip)]; + + let height = t + .rectangle_height + .or_else(|| t.column_data.heights.first().copied()) + .unwrap_or(0.0); + if height > 0.0 && !(columns_active && t.column_data.auto_height) { + let (sin, cos) = t.rotation.sin_cos(); + let down = glam::DVec3::new(sin, -cos, 0.0); + let (_, vertical) = attach_anchors(t); + let factor = match vertical { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + }; + result.push(triangle_grip(2, p + down * (factor * height))); + } + if columns_active { + result.push(triangle_grip(3, p + dir * (k * t.column_data.width))); + result.push(triangle_grip( + 4, + p + dir * (k * (t.column_data.width + t.column_data.gutter)), + )); + } + result } fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str { @@ -371,20 +423,6 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { .collect(), }, }, - // Count / width / gutter are live only when columns are on. - num_row( - t!("Column count").as_ref(), - "col_count", - t.column_data.column_count as f64, - col_type != 0, - ), - num_row(t!("Column width").as_ref(), "col_width", t.column_data.width, col_type != 0), - num_row( - t!("Column gutter").as_ref(), - "col_gutter", - t.column_data.gutter, - col_type != 0, - ), Property { label: t!("Text frame").into_owned(), field: "text_frame", @@ -502,21 +540,21 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) { "ins_y" => t.insertion_point.y = v, "ins_z" => t.insertion_point.z = v, "height" if v > 0.0 => t.height = v, - "rect_w" if v > 0.0 => t.rectangle_width = v, - "rect_h" if v > 0.0 => t.rectangle_height = Some(v), + "rect_w" if v >= 0.0 => t.rectangle_width = v, + "rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v), "rotation" => t.rotation = v.to_radians(), - "line_spacing" if v > 0.0 => t.line_spacing_factor = v, + "line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v, // Editing the absolute distance back-solves the line-spacing factor so // the two stay consistent (distance = height × 5/3 × factor). "line_space_distance" if v > 0.0 => { let denom = t.height * 1.666_666_666_666_667; if denom > 0.0 { - t.line_spacing_factor = v / denom; + let factor = v / denom; + if (0.25..=4.0).contains(&factor) { + t.line_spacing_factor = factor; + } } } - "col_count" if v >= 1.0 => t.column_data.column_count = v.round() as i32, - "col_width" if v > 0.0 => t.column_data.width = v, - "col_gutter" if v >= 0.0 => t.column_data.gutter = v, _ => {} } } @@ -541,7 +579,57 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) { let dx = p.x as f64 - t.insertion_point.x; let dy = p.y as f64 - t.insertion_point.y; let projected = dx * dir.x + dy * dir.y; - t.rectangle_width = (projected / k).max(0.01); + let width = (projected / k).max(0.0); + if t.column_data.column_type != 0 && t.column_data.column_count > 1 { + let gaps = (t.column_data.column_count - 1) as f64; + t.column_data.width = + ((width - t.column_data.gutter * gaps) / t.column_data.column_count as f64) + .max(0.01); + } else { + t.rectangle_width = width; + } + } + (2, GripApply::Absolute(p)) => { + let (sin, cos) = t.rotation.sin_cos(); + let down = glam::DVec3::new(sin, -cos, 0.0); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + p.z as f64 - t.insertion_point.z, + ); + let (_, vertical) = attach_anchors(t); + let factor = match vertical { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + }; + let height = (delta.dot(down) / factor).max(0.01); + t.rectangle_height = Some(height); + if t.column_data.column_type != 0 && !t.column_data.auto_height { + t.column_data.heights = + vec![height; t.column_data.column_count.max(1) as usize]; + } + } + (3, GripApply::Absolute(p)) => { + let (dir, k) = width_grip_axis(t); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + 0.0, + ); + t.column_data.width = (delta.dot(dir) / k).max(0.01); + } + (4, GripApply::Absolute(p)) => { + let (dir, k) = width_grip_axis(t); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + 0.0, + ); + t.column_data.gutter = + (delta.dot(dir) / k - t.column_data.width).max(0.0); } _ => {} } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index ed985a38..4084a780 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option ([f64; 2], [f64; 3]) { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -424,6 +430,11 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: ctx.line_spacing_factor as f32, + exact_line_spacing: matches!( + ctx.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: 0.0, vertical_text: vertical, want_glyph_boxes: false, }); @@ -2039,6 +2050,11 @@ impl MultiLeaderTess for MultiLeader { attach_h_anchor: h_anchor, v_anchor, line_spacing_factor: ctx.line_spacing_factor as f32, + exact_line_spacing: matches!( + ctx.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: 0.0, // Vertical flow (top-to-bottom) is stored per-context. vertical_text: matches!( ctx.text_flow_direction, diff --git a/src/entities/table.rs b/src/entities/table.rs index 6660c8c6..0a75045d 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -1187,6 +1187,7 @@ impl RenderConvertible for Table { oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } }; @@ -1287,6 +1288,8 @@ impl RenderConvertible for Table { attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -1453,6 +1456,7 @@ pub fn tessellate_table( oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } }; @@ -1920,6 +1924,8 @@ pub fn tessellate_table( attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 0ddc32aa..0ccd9892 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -11,6 +11,7 @@ pub struct ResolvedTextStyle { pub oblique_angle: f32, pub is_backward: bool, pub is_upside_down: bool, + pub is_vertical: bool, } pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle { @@ -76,6 +77,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } } @@ -1012,6 +1014,11 @@ pub struct MTextRenderOpts<'a> { pub v_anchor: MTextVAnchor, /// DXF code 44 — multiplier on the default 5/3-em baseline gap. pub line_spacing_factor: f32, + /// `true` fixes every baseline advance to the entity spacing. `false` + /// treats it as a minimum and expands for taller inline runs. + pub exact_line_spacing: bool, + /// User-defined text-box/column height. Zero means content-driven. + pub rectangle_height: f32, /// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2). pub vertical_text: bool, /// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with @@ -1025,10 +1032,9 @@ pub struct MTextRenderOpts<'a> { /// An MTEXT's column layout, flattened from its `column_data`. /// -/// Content moves to the next column at a `\N` break. Filling a column and -/// spilling into the next on its own is not modelled: `heights` / `auto_height` -/// are not read, so a column runs as long as its content does. -#[derive(Clone, Copy, Debug, Default)] +/// Content moves to the next column at an explicit `\N` break or when the +/// configured manual/automatic column height is exhausted. +#[derive(Clone, Debug, Default)] pub struct MTextColumns { /// Column count. 0 or 1 both mean "no column layout". pub count: usize, @@ -1036,6 +1042,12 @@ pub struct MTextColumns { pub width: f32, /// Gap between two adjacent columns, world units. pub gutter: f32, + /// Whether logical column flow starts at the opposite side. + pub flow_reversed: bool, + /// Dynamic-column height is balanced from content when true. + pub auto_height: bool, + /// Optional manual height for each logical column. + pub heights: Vec, } impl MTextColumns { @@ -1046,7 +1058,12 @@ impl MTextColumns { /// Left edge of column `i`, relative to the text block's own left edge. pub fn offset_of(&self, i: usize) -> f32 { - i as f32 * (self.width + self.gutter) + let physical = if self.flow_reversed { + self.count.saturating_sub(1).saturating_sub(i) + } else { + i + }; + physical as f32 * (self.width + self.gutter) } } @@ -1128,7 +1145,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { line_spacing: Option, } - let cols = opts.columns; + let cols = opts.columns.clone(); // A `\N` past the last column has nowhere to go — keep those lines in the // last one rather than laying them out beyond the block. let last_col = cols.count.saturating_sub(1); @@ -1366,15 +1383,55 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { }; // A paragraph's own `\psm#`/`\pse#` overrides the entity factor for // its lines; `Exact` fixes the baseline gap outright. + let entity_gap = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing(); match s.line_spacing { Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e, Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => { mh * m * (5.0 / 3.0) * base_font.line_spacing() } - _ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(), + _ if opts.exact_line_spacing => entity_gap, + _ => (mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing()) + .max(entity_gap), } }) .collect(); + + // Flow lines into the next column when the active column's configured + // height is exhausted. Explicit `\N` breaks remain authoritative. Dynamic + // auto-height balances the content; static/manual columns use their stored + // height (falling back to the entity rectangle height). + if cols.active() { + let total_advance: f32 = per_line_h.iter().sum(); + let balanced = (total_advance / cols.count.max(1) as f32).max(entity_h); + let mut current_column = 0usize; + let mut used = 0.0_f32; + for (index, line) in sub_lines.iter_mut().enumerate() { + if line.column > current_column { + current_column = line.column.min(cols.count - 1); + used = 0.0; + } + let limit = if cols.auto_height { + balanced + } else { + cols.heights + .get(current_column) + .copied() + .filter(|height| *height > 0.0) + .unwrap_or(opts.rectangle_height) + }; + let advance = per_line_h.get(index).copied().unwrap_or(entity_h); + if limit > 0.0 + && used > 0.0 + && used + advance > limit + && current_column + 1 < cols.count + { + current_column += 1; + used = 0.0; + } + line.column = current_column; + used += advance; + } + } // Per-line text height — the tallest Word on the line, an empty line // inheriting the previous line's height (same rule as `per_line_h`). Unlike // `line_max_h` it has no `entity_h` floor, so it reflects the ACTUAL text @@ -2169,6 +2226,7 @@ mod tests { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, } } @@ -2199,6 +2257,8 @@ mod tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -2222,6 +2282,8 @@ mod tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -2378,6 +2440,7 @@ mod v_anchor_tests { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -2390,6 +2453,8 @@ mod v_anchor_tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: true, }) diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index fe199e88..d60bbfd6 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) { + if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) + || matches!(entity, EntityType::MText(_)) + { section.props.retain(|prop| prop.field != "handle"); } From af4401ce675480239d3bdb353492c01772f35058 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:23 +0300 Subject: [PATCH 78/98] Complete MText editor controls and persistence --- src/app/mod.rs | 25 ++ src/app/mtext_editor.rs | 500 ++++++++++++++++++++++++++++++++++++++-- src/app/update/mod.rs | 121 +++++++++- src/app/view/overlay.rs | 165 ++++++++++++- 4 files changed, 775 insertions(+), 36 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 8324cf55..7223af75 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2655,6 +2655,31 @@ pub enum Message { MTextWidth(String), /// Toolbar character-spacing field changed. MTextCharSpace(String), + /// Undo / redo editor text and inline-format operations. + MTextUndo, + MTextRedo, + /// Convert the selected numerator/separator/denominator to a stacked run. + MTextStack, + /// Remove inline character formatting from the selection (or all text). + MTextClearFormatting, + /// Insert a predefined symbol or field token at the caret. + MTextInsert(String), + /// Per-object annotation flag edited from the text toolbar. + MTextAnnotative(bool), + /// Column layout controls. + MTextColumnMode(String), + MTextColumnCount(String), + MTextColumnWidth(String), + MTextColumnGutter(String), + MTextColumnHeight(String), + MTextColumnFlowReversed(bool), + /// Paragraph indent/spacing controls. + MTextParagraphNumber(mtext_editor::ParaNumber, String), + MTextFindText(String), + MTextReplaceText(String), + MTextFindNext, + MTextReplaceNext, + MTextReplaceAll, /// Toolbar colour picker (same widget as Properties) — applies to the /// selection, or the whole text when nothing is selected. MTextColorChanged(AcadColor), diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index f5dae78e..5d6c851c 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -8,7 +8,7 @@ use acadrust::entities::mtext::AttachmentPoint; use acadrust::entities::mtext_format::{ parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment, - MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, + MTextScalar, MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle, MText}; @@ -38,6 +38,15 @@ pub enum ParaAlign { Justify, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParaNumber { + FirstIndent, + LeftIndent, + RightIndent, + SpaceBefore, + SpaceAfter, +} + /// `pick_list`-friendly wrapper for the 9 attachment points. #[derive(Clone, Copy, PartialEq, Eq)] pub struct JustifyChoice(pub AttachmentPoint); @@ -107,6 +116,23 @@ pub struct MTextEditorState { /// it is NOT derived from the typed content, so adding characters wraps /// to the next line instead of stretching the box into one long line. pub rect_width: f64, + pub rect_height: String, + pub annotative: bool, + pub column_type: i16, + pub column_count: String, + pub column_auto_height: bool, + pub column_flow_reversed: bool, + pub column_width: String, + pub column_gutter: String, + pub paragraph_first_indent: String, + pub paragraph_left_indent: String, + pub paragraph_right_indent: String, + pub paragraph_space_before: String, + pub paragraph_space_after: String, + pub find_text: String, + pub replace_text: String, + undo: Vec, + redo: Vec, /// `Some` when editing an existing entity; `None` for a fresh MText. pub editing: Option, /// When true the panel shows the rendered preview; when false the raw @@ -167,6 +193,23 @@ impl MTextEditorState { // Default box ~20 characters wide; overwritten with the entity's // own width when editing an existing MText. rect_width: (height * 20.0).max(1.0), + rect_height: "0".to_string(), + annotative: false, + column_type: 0, + column_count: "2".to_string(), + column_auto_height: false, + column_flow_reversed: false, + column_width: (height * 10.0).to_string(), + column_gutter: (height * 5.0).to_string(), + paragraph_first_indent: "0".to_string(), + paragraph_left_indent: "0".to_string(), + paragraph_right_indent: "0".to_string(), + paragraph_space_before: "0".to_string(), + paragraph_space_after: "0".to_string(), + find_text: String::new(), + replace_text: String::new(), + undo: Vec::new(), + redo: Vec::new(), editing, screen_anchor: iced::Point::new(60.0, 90.0), original: None, @@ -274,8 +317,53 @@ impl MTextEditorState { mt.attachment_point = self.attachment; mt.line_spacing_factor = self.line_spacing as f64; mt.style = self.style.clone(); + mt.is_annotative = self.annotative; + let rectangle_height = self.rect_height.trim().parse::().unwrap_or(0.0); + mt.rectangle_height = (rectangle_height > 0.0).then_some(rectangle_height); + mt.column_data.column_type = self.column_type; + if self.column_type == 0 { + mt.column_data.column_count = 0; + mt.column_data.heights.clear(); + } else { + mt.column_data.column_count = self + .column_count + .trim() + .parse::() + .ok() + .filter(|count| *count > 0) + .unwrap_or(1); + mt.column_data.auto_height = self.column_auto_height; + mt.column_data.flow_reversed = self.column_flow_reversed; + if let Ok(width) = self.column_width.trim().parse::() { + if width > 0.0 { + mt.column_data.width = width; + } + } + if let Ok(gutter) = self.column_gutter.trim().parse::() { + if gutter >= 0.0 { + mt.column_data.gutter = gutter; + } + } + if !self.column_auto_height && rectangle_height > 0.0 { + mt.column_data.heights = vec![ + rectangle_height; + mt.column_data.column_count.max(1) as usize + ]; + } + } mt } + + fn record_undo(&mut self) { + let value = self.content.text(); + if self.undo.last().is_none_or(|last| last != &value) { + self.undo.push(value); + if self.undo.len() > 100 { + self.undo.remove(0); + } + } + self.redo.clear(); + } } /// Parse a numeric field, returning `Some(v)` only when it differs from the @@ -595,6 +683,24 @@ fn vertical_caret_target( best.1 } +fn seed_mtext_state(state: &mut MTextEditorState, m: &MText) { + state.attachment = m.attachment_point; + state.line_spacing = m.line_spacing_factor as f32; + state.rect_width = m.rectangle_width.max(0.0); + state.rect_height = m.rectangle_height.unwrap_or(0.0).max(0.0).to_string(); + state.annotative = m.is_annotative; + state.column_type = m.column_data.column_type; + state.column_count = m.column_data.column_count.max(1).to_string(); + state.column_auto_height = m.column_data.auto_height; + state.column_flow_reversed = m.column_data.flow_reversed; + state.column_width = m.column_data.width.max(0.0).to_string(); + state.column_gutter = m.column_data.gutter.max(0.0).to_string(); + if !m.style.trim().is_empty() { + state.style = m.style.clone(); + } + state.original = Some(m.clone()); +} + impl super::OpenCADStudio { /// Open the in-place editor for a new (`handle = None`) or existing MText. /// Open the rich MText editor for a new or existing MText / MultiLeader. @@ -605,6 +711,7 @@ impl super::OpenCADStudio { handle: Option, initial: &str, height: f64, + template: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -614,20 +721,11 @@ impl super::OpenCADStudio { state.screen_anchor = p; } // Seed attachment / line-spacing / box width from the entity being edited. + let has_template = template.is_some(); if let Some(h) = handle { match self.tabs[self.active_tab].scene.document.get_entity(h) { Some(EntityType::MText(m)) => { - state.attachment = m.attachment_point; - state.line_spacing = m.line_spacing_factor as f32; - if !m.style.trim().is_empty() { - state.style = m.style.clone(); - } - if m.rectangle_width > 0.0 { - state.rect_width = m.rectangle_width; - } - // Keep the whole entity so `build_mtext` preserves the fields - // the toolbar can't edit (columns, rotation, background…). - state.original = Some(m.clone()); + seed_mtext_state(&mut state, m); } Some(EntityType::MultiLeader(ml)) => { state.line_spacing = ml.context.line_spacing_factor as f32; @@ -637,6 +735,8 @@ impl super::OpenCADStudio { } _ => {} } + } else if let Some(m) = template { + seed_mtext_state(&mut state, &m); } else { // New MText inherits the document's current text style (STYLE), // not the "Standard" default. See #92. @@ -659,7 +759,7 @@ impl super::OpenCADStudio { // The preview uses a fixed on-screen text size. Therefore the initial // wrap width is the drawing-unit span that exactly reaches the right // edge of the editor, placing both ruler and slider at their maximum. - if handle.is_none() { + if handle.is_none() && !has_template { let initial_width = self.mtext_editor.as_ref().map(|ed| { super::view::overlay::MTEXT_EDITOR_WRITING_WIDTH / ed.preview_scale() }); @@ -746,6 +846,318 @@ impl super::OpenCADStudio { /// Splice text around the preview selection (visible-char range) in the /// raw value. `case` optionally transforms the selected slice. Returns /// true when a preview selection was present and applied. + pub(super) fn mtext_undo(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + if let Some(previous) = ed.undo.pop() { + ed.redo.push(ed.content.text()); + ed.content = text_editor::Content::with_text(&previous); + ed.caret = previous.chars().count(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_redo(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + if let Some(next) = ed.redo.pop() { + ed.undo.push(ed.content.text()); + ed.content = text_editor::Content::with_text(&next); + ed.caret = next.chars().count(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_span_number(&mut self, field: &'static str, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + let valid = match field { + "height" => number > 0.0, + "oblique" => (-85.0..=85.0).contains(&number), + "width" => number > 0.0, + "tracking" => number.is_finite(), + _ => false, + }; + if !valid { + return; + } + let has_selection = self + .mtext_editor + .as_ref() + .and_then(|editor| editor.sel) + .is_some_and(|(start, end)| start < end); + if let Some(ed) = self.mtext_editor.as_mut() { + if has_selection { + let (start, end) = ed.sel.unwrap(); + ed.record_undo(); + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + match field { + "height" => props.height = Some(MTextScalar::Absolute(number)), + "oblique" => props.oblique_angle = (number != 0.0).then_some(number), + "width" => props.width_factor = (number != 1.0).then_some(number), + "tracking" => props.tracking = (number != 0.0).then_some(number), + _ => {} + } + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } else { + match field { + "height" => ed.height = value, + "oblique" => ed.oblique = value, + "width" => ed.width = value, + "tracking" => ed.char_space = value, + _ => {} + } + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_paragraph_number(&mut self, field: ParaNumber, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + if !number.is_finite() { + return; + } + if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); + match field { + ParaNumber::FirstIndent => ed.paragraph_first_indent = value, + ParaNumber::LeftIndent => ed.paragraph_left_indent = value, + ParaNumber::RightIndent => ed.paragraph_right_indent = value, + ParaNumber::SpaceBefore => ed.paragraph_space_before = value, + ParaNumber::SpaceAfter => ed.paragraph_space_after = value, + } + let para0 = doc_para0(&ed.doc); + let cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((ed.caret, ed.caret)); + let paragraph_of = |index: usize| { + cells + .iter() + .take(index.min(cells.len())) + .filter(|cell| matches!(cell, Cell::Break(..))) + .count() + }; + let first = paragraph_of(start); + let last = paragraph_of(end.saturating_sub((end > start) as usize)); + let mut doc = cells_to_doc(¶0, &cells); + for paragraph in doc + .paragraphs + .iter_mut() + .skip(first) + .take(last.saturating_sub(first) + 1) + { + let optional = (number != 0.0).then_some(number); + match field { + ParaNumber::FirstIndent => paragraph.properties.first_line_indent = optional, + ParaNumber::LeftIndent => paragraph.properties.left_margin = optional, + ParaNumber::RightIndent => paragraph.properties.right_margin = optional, + ParaNumber::SpaceBefore => paragraph.properties.spacing_before = optional, + ParaNumber::SpaceAfter => paragraph.properties.spacing_after = optional, + } + } + ed.content = text_editor::Content::with_text(&doc.to_mtext_string()); + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_stack_selection(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let Some((start, end)) = ed.sel.filter(|(start, end)| start < end) else { + return; + }; + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + let plain = cells_to_doc(&ParagraphProperties::default(), &cells[start..end]) + .to_plain_text(); + let Some((split, separator)) = plain + .char_indices() + .find(|(_, ch)| matches!(ch, '/' | '#' | '^')) + else { + return; + }; + let separator_len = separator.len_utf8(); + let data = StackingData { + numerator: plain[..split].to_string(), + denominator: plain[split + separator_len..].to_string(), + stacking_type: StackingType::from_char(separator), + }; + if data.numerator.is_empty() || data.denominator.is_empty() { + return; + } + let props = cells[start..end] + .iter() + .find_map(|cell| match cell { + Cell::Char(_, props) | Cell::Stack { props, .. } => Some(props.clone()), + Cell::Break(..) => None, + }) + .unwrap_or_default(); + ed.record_undo(); + let replacement = flatten_stack(&data) + .chars() + .enumerate() + .map(|(index, _)| Cell::Stack { + data: data.clone(), + props: props.clone(), + head: index == 0, + }) + .collect::>(); + let replacement_len = replacement.len(); + cells.splice(start..end, replacement); + let new_end = start + replacement_len; + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, new_end)); + ed.caret = new_end; + ed.sel_anchor = start; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_clear_formatting(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((0, cells.len())); + let end = end.min(cells.len()); + ed.record_undo(); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + *props = SpanProperties::default(); + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_find_next(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let cells = doc_to_cells(&ed.doc); + let chars: Vec> = cells + .iter() + .map(|cell| match cell { + Cell::Char(ch, _) => Some(*ch), + Cell::Break(..) => Some('\n'), + Cell::Stack { .. } => None, + }) + .collect(); + let start = ed.caret.min(chars.len()); + let found = (start..=chars.len().saturating_sub(needle.len())) + .chain(0..start.min(chars.len().saturating_sub(needle.len()) + 1)) + .find(|index| { + chars[*index..*index + needle.len()] + .iter() + .zip(&needle) + .all(|(actual, expected)| actual == &Some(*expected)) + }); + if let Some(found) = found { + ed.sel_anchor = found; + ed.sel = Some((found, found + needle.len())); + ed.caret = found + needle.len(); + ed.caret_blink_on = true; + } + } + } + + pub(super) fn mtext_replace_next(&mut self) { + let replacement = self + .mtext_editor + .as_ref() + .map(|editor| editor.replace_text.clone()) + .unwrap_or_default(); + let matches = self.mtext_editor.as_ref().is_some_and(|editor| { + let Some((start, end)) = editor.sel.filter(|(start, end)| start < end) else { + return false; + }; + let cells = doc_to_cells(&editor.doc); + cells_to_doc( + &ParagraphProperties::default(), + &cells[start.min(cells.len())..end.min(cells.len())], + ) + .to_plain_text() + == editor.find_text + }); + if matches { + self.mtext_type(&replacement); + } + self.mtext_find_next(); + } + + pub(super) fn mtext_replace_all(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let replacement = ed.replace_text.clone(); + let mut matches = Vec::new(); + let mut index = 0usize; + while index + needle.len() <= cells.len() { + let found = cells[index..index + needle.len()] + .iter() + .zip(&needle) + .all(|(cell, expected)| matches!(cell, Cell::Char(actual, _) if actual == expected)); + if found { + matches.push(index); + index += needle.len(); + } else { + index += 1; + } + } + if matches.is_empty() { + return; + } + ed.record_undo(); + for start in matches.into_iter().rev() { + let props = insert_props(&cells, start); + let paragraph = para_props_at(¶0, &cells, start); + let replacement_cells = str_to_cells(&replacement, &props, ¶graph); + cells.splice(start..start + needle.len(), replacement_cells); + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.caret = cells.len(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + self.rebuild_mtext_preview(); + } + /// Toggle a character format over the preview selection, on the structured /// span properties. The stroke-font renderer has no true bold, so Bold /// switches the run to the heavier "Gothic" face; Italic applies a 15° @@ -798,6 +1210,7 @@ impl super::OpenCADStudio { if a >= b { return; } + ed.record_undo(); match kind { MTextFmt::Underline => { let on = !all_have(&cells[a..b], |p| p.underline()); @@ -882,6 +1295,7 @@ impl super::OpenCADStudio { /// Set the alignment of every paragraph the selection (or caret) touches. pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let cells = doc_to_cells(&ed.doc); let (a, b) = ed @@ -933,6 +1347,7 @@ impl super::OpenCADStudio { s }; if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); // Edit the structured document as a flat cell list, then serialize // it back into the raw content the preview/commit still read. let para0 = doc_para0(&ed.doc); @@ -962,6 +1377,7 @@ impl super::OpenCADStudio { /// Delete the selection, or the visible character before the caret. pub(super) fn mtext_backspace(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); @@ -985,6 +1401,7 @@ impl super::OpenCADStudio { /// Delete the selection, or the visible character at the caret. pub(super) fn mtext_delete(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); @@ -1104,6 +1521,7 @@ impl super::OpenCADStudio { let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { + ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { let (bold, italic) = p @@ -1155,6 +1573,7 @@ impl super::OpenCADStudio { let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { + ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { p.color = mcolor.clone(); @@ -1217,7 +1636,8 @@ impl super::OpenCADStudio { } let body_empty = ed.content.text().trim().is_empty(); let mut mt = ed.build_mtext(); - let annotative = ed.editing.is_none() + let annotative = mt.is_annotative + || ed.editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, @@ -1240,9 +1660,12 @@ impl super::OpenCADStudio { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; + t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; + t.rectangle_height = mt.rectangle_height; + t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; @@ -1255,9 +1678,25 @@ impl super::OpenCADStudio { _ => {} } // Keep the displayed annotation context in sync. + if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + h, + annotative, + ); + if annotative { + if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + h, + scale, + ); + } + } + } if matches!( self.tabs[i].scene.document.get_entity(h), - Some(EntityType::MultiLeader(_)) + Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene @@ -1283,7 +1722,6 @@ impl super::OpenCADStudio { position.y, position.z, ); - mt.rotation = 0.0; self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); if annotative { @@ -1321,7 +1759,8 @@ impl super::OpenCADStudio { ), None => return, }; - let annotative = editing.is_none() + let annotative = mt.is_annotative + || editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, @@ -1341,9 +1780,12 @@ impl super::OpenCADStudio { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; + t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; + t.rectangle_height = mt.rectangle_height; + t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; @@ -1355,9 +1797,25 @@ impl super::OpenCADStudio { } _ => {} } + if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + h, + annotative, + ); + if annotative { + if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + h, + scale, + ); + } + } + } if matches!( self.tabs[i].scene.document.get_entity(h), - Some(EntityType::MultiLeader(_)) + Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene @@ -1383,7 +1841,6 @@ impl super::OpenCADStudio { position.y, position.z, ); - mt.rotation = 0.0; self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); self.tabs[i].dirty = true; @@ -1400,6 +1857,11 @@ impl super::OpenCADStudio { // Bind the editor to the fresh entity so the next Apply updates it. if let (Some(h), Some(ed)) = (handle, self.mtext_editor.as_mut()) { ed.editing = Some(h); + if let Some(EntityType::MText(mtext)) = + self.tabs[i].scene.document.get_entity(h) + { + ed.original = Some(mtext.clone()); + } } } self.refresh_properties(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a98bb676..4d9b1994 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -3880,10 +3880,7 @@ impl OpenCADStudio { Task::none() } Message::MTextHeight(s) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.height = s; - } - self.rebuild_mtext_preview(); + self.mtext_apply_span_number("height", s); Task::none() } Message::MTextRectWidth(width) => { @@ -3915,26 +3912,128 @@ impl OpenCADStudio { Task::none() } Message::MTextOblique(s) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.oblique = s; - } - self.rebuild_mtext_preview(); + self.mtext_apply_span_number("oblique", s); Task::none() } Message::MTextWidth(s) => { + self.mtext_apply_span_number("width", s); + Task::none() + } + Message::MTextCharSpace(s) => { + self.mtext_apply_span_number("tracking", s); + Task::none() + } + Message::MTextUndo => { + self.mtext_undo(); + Task::none() + } + Message::MTextRedo => { + self.mtext_redo(); + Task::none() + } + Message::MTextStack => { + self.mtext_stack_selection(); + Task::none() + } + Message::MTextClearFormatting => { + self.mtext_clear_formatting(); + Task::none() + } + Message::MTextInsert(value) => { + self.mtext_type(&value); + Task::none() + } + Message::MTextAnnotative(value) => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.width = s; + ed.annotative = value; } self.rebuild_mtext_preview(); Task::none() } - Message::MTextCharSpace(s) => { + Message::MTextColumnMode(mode) => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.char_space = s; + match mode.as_str() { + "Static" => { + ed.column_type = 1; + ed.column_auto_height = false; + } + "Dynamic auto" => { + ed.column_type = 2; + ed.column_auto_height = true; + } + "Dynamic manual" => { + ed.column_type = 2; + ed.column_auto_height = false; + } + _ => ed.column_type = 0, + } } self.rebuild_mtext_preview(); Task::none() } + Message::MTextColumnCount(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_count = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnWidth(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_width = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnGutter(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_gutter = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnHeight(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.rect_height = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnFlowReversed(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_flow_reversed = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextParagraphNumber(field, value) => { + self.mtext_apply_paragraph_number(field, value); + Task::none() + } + Message::MTextFindText(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.find_text = value; + } + Task::none() + } + Message::MTextReplaceText(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.replace_text = value; + } + Task::none() + } + Message::MTextFindNext => { + self.mtext_find_next(); + Task::none() + } + Message::MTextReplaceNext => { + self.mtext_replace_next(); + Task::none() + } + Message::MTextReplaceAll => { + self.mtext_replace_all(); + Task::none() + } Message::MTextJustify(ap) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.attachment = ap; diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index b5782f81..c1c4dac3 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -6,7 +6,8 @@ use iced::advanced::renderer; use iced::advanced::widget; use iced::advanced::{Layout, Shell, Widget}; use iced::widget::{ - button, column, container, mouse_area, row, scrollable, stack, text, text_input, Space, + button, checkbox, column, container, mouse_area, row, scrollable, stack, text, text_input, + Space, }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; @@ -374,7 +375,7 @@ pub(super) fn mtext_editor_overlay<'a>( iced::widget::Space::new().width(Fill).height(Fill), t!("Text Editor"), content, - Message::MTextCancel, + Message::MTextOk, modal_offset, crate::ui::modal::ModalOptions::STANDARD, ) @@ -450,6 +451,9 @@ fn mtext_editor_content<'a>( .on_select(Message::MTextStyle) .text_size(11) .width(iced::Length::Fixed(96.0)); + let annotative = checkbox(ed.annotative) + .on_toggle(Message::MTextAnnotative) + .size(14); let font_sel = if ed.font.trim().is_empty() { "[Style default]".to_string() } else { @@ -487,6 +491,9 @@ fn mtext_editor_content<'a>( let row1 = row![ style_pl, + row![annotative, text(t!("Annotative")).size(11)] + .spacing(3) + .align_y(iced::Alignment::Center), font_pl, small_input("2.5", &ed.height, Message::MTextHeight, 64.0), iced::widget::Space::new().width(6), @@ -535,6 +542,14 @@ fn mtext_editor_content<'a>( .text_size(11) .width(iced::Length::Fixed(112.0)); let row2 = row![ + button(lbl("↶")) + .on_press(Message::MTextUndo) + .padding(3) + .style(btn_style), + button(lbl("↷")) + .on_press(Message::MTextRedo) + .padding(3) + .style(btn_style), lbl("O"), small_input("0", &ed.oblique, Message::MTextOblique, 48.0), lbl("W"), @@ -573,6 +588,144 @@ fn mtext_editor_content<'a>( .on_press(Message::MTextLineSpacing(2.0)) .padding(3) .style(btn_style), + button(lbl("Stack")) + .on_press(Message::MTextStack) + .padding(3) + .style(btn_style), + button(lbl("Clear")) + .on_press(Message::MTextClearFormatting) + .padding(3) + .style(btn_style), + button(lbl("°")) + .on_press(Message::MTextInsert("°".to_string())) + .padding(3) + .style(btn_style), + button(lbl("±")) + .on_press(Message::MTextInsert("±".to_string())) + .padding(3) + .style(btn_style), + button(lbl("⌀")) + .on_press(Message::MTextInsert("⌀".to_string())) + .padding(3) + .style(btn_style), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + + let column_mode = match (ed.column_type, ed.column_auto_height) { + (1, _) => "Static", + (2, true) => "Dynamic auto", + (2, false) => "Dynamic manual", + _ => "No columns", + } + .to_string(); + let column_picker = iced::widget::pick_list( + Some(column_mode), + ["No columns", "Static", "Dynamic auto", "Dynamic manual"] + .into_iter() + .map(str::to_string) + .collect::>(), + |value| value.to_string(), + ) + .on_select(Message::MTextColumnMode) + .text_size(11) + .width(iced::Length::Fixed(120.0)); + let reverse = checkbox(ed.column_flow_reversed) + .on_toggle(Message::MTextColumnFlowReversed) + .size(14); + let row3 = row![ + lbl("Columns"), + column_picker, + lbl("Count"), + small_input("2", &ed.column_count, Message::MTextColumnCount, 42.0), + lbl("Height"), + text_input("0", &ed.rect_height) + .on_input(Message::MTextColumnHeight) + .width(iced::Length::Fixed(58.0)) + .padding(3) + .size(12), + lbl("Width"), + small_input("0", &ed.column_width, Message::MTextColumnWidth, 58.0), + lbl("Gutter"), + small_input("0", &ed.column_gutter, Message::MTextColumnGutter, 58.0), + reverse, + lbl("Reverse"), + iced::widget::Space::new().width(8), + lbl("First"), + text_input("0", &ed.paragraph_first_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::FirstIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Left"), + text_input("0", &ed.paragraph_left_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::LeftIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Right"), + text_input("0", &ed.paragraph_right_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::RightIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Before"), + text_input("0", &ed.paragraph_space_before) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceBefore, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("After"), + text_input("0", &ed.paragraph_space_after) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceAfter, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + let row4 = row![ + lbl("Find"), + text_input("Find", &ed.find_text) + .on_input(Message::MTextFindText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Next")) + .on_press(Message::MTextFindNext) + .padding(3) + .style(btn_style), + lbl("Replace"), + text_input("Replace", &ed.replace_text) + .on_input(Message::MTextReplaceText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Replace")) + .on_press(Message::MTextReplaceNext) + .padding(3) + .style(btn_style), + button(lbl("Replace All")) + .on_press(Message::MTextReplaceAll) + .padding(3) + .style(btn_style), ] .spacing(4) .align_y(iced::Alignment::Center) @@ -710,13 +863,13 @@ fn mtext_editor_content<'a>( .into() }; - // ── Top action bar: Apply on the right, exactly like the style managers' - // toolbar strip. Closing (the modal ✕) without applying discards the - // buffer, so there is no separate Cancel. + // ── Top action bar: Apply keeps editing; Close saves and exits. Escape + // remains the explicit discard path. let action_bar = container( row![ iced::widget::Space::new().width(width), crate::ui::style::style_manager::tb_button(t!("Apply"), Message::MTextApply, true), + crate::ui::style::style_manager::tb_button(t!("Close Text Editor"), Message::MTextOk, true), ] .align_y(iced::Alignment::Center), ) @@ -730,7 +883,7 @@ fn mtext_editor_content<'a>( .padding([5, 8]); container( - column![action_bar, row1, row2, width_slider, body] + column![action_bar, row1, row2, row3, row4, width_slider, body] .spacing(6) .width(width) .height(height), From 8b6724700eca3579b2f563eea2383f014705aca3 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:24 +0300 Subject: [PATCH 79/98] Render MText column masks independently --- src/scene/convert/tessellate.rs | 129 ++++++++++++++++++++++++++------ 1 file changed, 107 insertions(+), 22 deletions(-) diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d474c5f1..90f7c08d 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -78,6 +78,80 @@ fn oriented_text_corners( ] } +fn oriented_mtext_corner_groups( + verts: &[crate::scene::pipeline::text_gpu::TextVertex], + text: &acadrust::MText, + rotation: f64, + pad: f64, + annotation_scale: f64, +) -> Vec<[[f64; 2]; 4]> { + let columns = &text.column_data; + let count = columns.column_count.max(0) as usize; + if columns.column_type == 0 || count <= 1 || columns.width <= 0.0 { + return vec![oriented_text_corners( + verts, + [text.insertion_point.x, text.insertion_point.y], + rotation, + pad, + )]; + } + + let width = columns.width * annotation_scale; + let gutter = columns.gutter.max(0.0) * annotation_scale; + let total_width = width * count as f64 + gutter * count.saturating_sub(1) as f64; + let anchor = match text.attachment_point { + acadrust::entities::mtext::AttachmentPoint::TopCenter + | acadrust::entities::mtext::AttachmentPoint::MiddleCenter + | acadrust::entities::mtext::AttachmentPoint::BottomCenter => 0.5, + acadrust::entities::mtext::AttachmentPoint::TopRight + | acadrust::entities::mtext::AttachmentPoint::MiddleRight + | acadrust::entities::mtext::AttachmentPoint::BottomRight => 1.0, + _ => 0.0, + }; + let block_left = -anchor * total_width; + let origin = [text.insertion_point.x, text.insertion_point.y]; + let (sin_r, cos_r) = rotation.sin_cos(); + let mut bounds = vec![[f64::MAX, f64::MAX, f64::MIN, f64::MIN]; count]; + for vertex in verts { + let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0]; + let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1]; + let local_x = x * cos_r + y * sin_r; + let local_y = -x * sin_r + y * cos_r; + let stride = width + gutter; + let physical = ((local_x - block_left) / stride) + .floor() + .clamp(0.0, count.saturating_sub(1) as f64) as usize; + bounds[physical][0] = bounds[physical][0].min(local_x); + bounds[physical][1] = bounds[physical][1].min(local_y); + bounds[physical][2] = bounds[physical][2].max(local_x); + bounds[physical][3] = bounds[physical][3].max(local_y); + } + let to_world = |x: f64, y: f64| { + [ + origin[0] + x * cos_r - y * sin_r, + origin[1] + x * sin_r + y * cos_r, + ] + }; + bounds + .into_iter() + .filter(|bounds| bounds[0] <= bounds[2] && bounds[1] <= bounds[3]) + .map(|bounds| { + let [left, bottom, right, top] = [ + bounds[0] - pad, + bounds[1] - pad, + bounds[2] + pad, + bounds[3] + pad, + ]; + [ + to_world(left, bottom), + to_world(right, bottom), + to_world(right, top), + to_world(left, top), + ] + }) + .collect() +} + pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> { let EntityType::MText(text) = entity else { return None; @@ -849,11 +923,12 @@ pub fn tessellate( .unwrap_or(m.rotation); let text_height = m.height * anno; let pad = (m.background_scale - 1.0).max(0.0) * text_height; - let corners = oriented_text_corners( + let corner_groups = oriented_mtext_corner_groups( &sdf_verts, - [m.insertion_point.x, m.insertion_point.y], + m, text_rotation, pad, + anno, ); // Fill / mask — two triangles behind the glyphs. if has_fill { @@ -862,13 +937,18 @@ pub fn tessellate( } else { color_or_inherit(&m.background_color, bg_color) }; - let mut ft = Vec::with_capacity(6); - let mut ftl = Vec::with_capacity(6); - for &k in &[0usize, 1, 2, 0, 2, 3] { - let (h, lo) = - split_ds_xyz(corners[k][0], corners[k][1], elev_v); - ft.push(h); - ftl.push(lo); + let mut ft = Vec::with_capacity(6 * corner_groups.len()); + let mut ftl = Vec::with_capacity(6 * corner_groups.len()); + for corners in &corner_groups { + for &k in &[0usize, 1, 2, 0, 2, 3] { + let (h, lo) = split_ds_xyz( + corners[k][0], + corners[k][1], + elev_v, + ); + ft.push(h); + ftl.push(lo); + } } wires.push(WireModel { point_marker: None, @@ -907,19 +987,24 @@ pub fn tessellate( // Text frame — a closed rectangle in the text // colour around the same box. if has_frame { - let loop_xy = [ - corners[0], - corners[1], - corners[2], - corners[3], - corners[0], - ]; - let mut fp = Vec::with_capacity(5); - let mut fpl = Vec::with_capacity(5); - for &[x, y] in &loop_xy { - let (h, lo) = split_ds_xyz(x, y, elev_v); - fp.push(h); - fpl.push(lo); + let mut fp = Vec::with_capacity(6 * corner_groups.len()); + let mut fpl = Vec::with_capacity(6 * corner_groups.len()); + for (group_index, corners) in corner_groups.iter().enumerate() { + if group_index > 0 { + fp.push([f32::NAN; 3]); + fpl.push([0.0; 3]); + } + for &[x, y] in &[ + corners[0], + corners[1], + corners[2], + corners[3], + corners[0], + ] { + let (h, lo) = split_ds_xyz(x, y, elev_v); + fp.push(h); + fpl.push(lo); + } } wires.push(WireModel { point_marker: None, From b1df6dddd85964c4e508a8d1ff3dd97d670ac70c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:48:25 +0300 Subject: [PATCH 80/98] Use MText column persistence revision --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index babff425..32fa742b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9f3cf8e#9f3cf8e26d5a02fb4ad8ea145ca8e04dd6d6f2bc" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=96dee63f476cd449b95dad19ac89faf29cc19043#96dee63f476cd449b95dad19ac89faf29cc19043" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 02b96ca9..d7575f1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } From 1922492a951c3e4012127eaa466a403e59a37596 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:06:51 +0300 Subject: [PATCH 81/98] Make MText defined width read-only --- src/entities/mtext.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 3dcee115..b8f65ec6 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -300,10 +300,9 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { // by the line-spacing factor. let line_space_distance = t.height * 1.666_666_666_666_667 * t.line_spacing_factor; let text_frame_on = (t.background_fill_flags & 0x10) != 0; - // Defined width is only live without columns; defined height is live for - // static columns or manual-height dynamic columns, grayed otherwise. + // Defined width is reported by Properties but is not edited there. Defined + // height remains live for static columns or manual-height dynamic columns. let col_type = t.column_data.column_type; - let width_editable = col_type == 0; let height_editable = col_type == 1 || (col_type == 2 && !t.column_data.auto_height); vec![ PropSection { @@ -405,7 +404,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { .collect(), }, }, - num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, width_editable), + num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, false), num_row( t!("Defined height").as_ref(), "rect_h", @@ -540,7 +539,6 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) { "ins_y" => t.insertion_point.y = v, "ins_z" => t.insertion_point.z = v, "height" if v > 0.0 => t.height = v, - "rect_w" if v >= 0.0 => t.rectangle_width = v, "rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v), "rotation" => t.rotation = v.to_radians(), "line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v, From de294447a93e7a687b7bb112a5f2d179fc0852c8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:54:20 +0300 Subject: [PATCH 82/98] Complete geometric tolerance workflow and properties --- src/app/commands/dim.rs | 5 +- src/app/mod.rs | 13 + src/app/properties.rs | 75 +++- src/app/text_inline.rs | 7 + src/app/tolerance_dialog.rs | 88 +++++ src/app/update/command.rs | 89 ++++- src/app/update/mod.rs | 35 ++ src/app/view/modal.rs | 7 + src/entities/tolerance.rs | 266 ++++++++++---- src/modules/annotate/tolerance_cmd.rs | 102 +++--- src/scene/cache/properties.rs | 4 +- src/scene/convert/tess.rs | 1 + src/ui/window/geometric_tolerance.rs | 479 ++++++++++++++++++++++++++ src/ui/window/mod.rs | 1 + 14 files changed, 1037 insertions(+), 135 deletions(-) create mode 100644 src/app/tolerance_dialog.rs create mode 100644 src/ui/window/geometric_tolerance.rs diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 0138c9fc..31ef8651 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -594,10 +594,7 @@ impl OpenCADStudio { } "TOLERANCE" => { - use crate::modules::annotate::tolerance_cmd::ToleranceCommand; - let cmd = ToleranceCommand::new(); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.open_tolerance_dialog(None); } "TABLE" => { diff --git a/src/app/mod.rs b/src/app/mod.rs index 8324cf55..d9c5bc13 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -22,6 +22,7 @@ pub(crate) mod settings; mod shortcuts; mod style_ops; mod text_inline; +mod tolerance_dialog; mod update; mod view; mod visibility; @@ -399,6 +400,8 @@ pub(super) struct OpenCADStudio { layer_translator: Option, /// Working copy of the Drawing Units dialog; `None` while it is closed. drawing_units: Option, + /// Working copy of the structured feature-control-frame editor. + geometric_tolerance: Option, /// PICKDRAG (#226): false (default) = press-drag lassoes; true = /// press-drag draws a rectangle marquee. pick_drag_rect: bool, @@ -1588,6 +1591,7 @@ pub enum ModalKind { LayerStateManager, LayerTranslator, DrawingUnits, + GeometricTolerance, DraftingSettings, LayerStateEditor, Plot, @@ -2318,6 +2322,14 @@ pub enum Message { DrawingUnitsField(crate::ui::window::drawing_units::Field), /// Drawing Units OK — write the working copy into the drawing. DrawingUnitsApply, + /// One structured feature-control-frame field changed. + ToleranceDialogField(crate::ui::window::geometric_tolerance::Field), + /// One structured feature-control-frame option changed. + ToleranceDialogToggle(crate::ui::window::geometric_tolerance::Toggle), + /// Apply edits without closing the structured editor. + ToleranceDialogApply, + /// Commit edits or continue to insertion-point placement. + ToleranceDialogOk, /// Toggle the Isolate pill's action menu open/closed. ToggleIsolatePopup, /// Close the Isolate action menu. @@ -3145,6 +3157,7 @@ impl OpenCADStudio { last_layer_translation: None, layer_translator: None, drawing_units: None, + geometric_tolerance: None, pick_drag_rect: false, perf_hud: false, cycle_candidates: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index bd7daa3c..d4e51d93 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2,6 +2,7 @@ use super::helpers::{entity_type_key, entity_type_label, title_case_word}; use super::{OpenCADStudio, VARIES_LABEL}; use crate::io::linetypes; use crate::scene::view::dispatch; +use crate::scene::model::object::PropValue; use crate::ui; use crate::t; use acadrust::types::{Transform, Vector3}; @@ -1310,11 +1311,77 @@ impl OpenCADStudio { // Feature-control frame: FCF text style is the dimension // style's DIMTXSTY. acadrust::EntityType::Tolerance(tol) => { - if let Some(ds) = find_dim_style(doc, &tol.dimension_style_name) { - if !ds.dimtxsty.is_empty() { - set_row(&mut sections, "tol_text_style", ds.dimtxsty.clone()); - } + use crate::entities::dim_override as dov; + let style = crate::entities::tolerance::resolve_dim_style(tol, doc); + let style_name = style + .map(|entry| entry.name.clone()) + .unwrap_or_else(|| { + if tol.dimension_style_name.trim().is_empty() { + "Standard".to_string() + } else { + tol.dimension_style_name.clone() + } + }); + let mut dim_style_names: Vec = doc + .dim_styles + .iter() + .map(|entry| entry.name.clone()) + .filter(|name| !name.trim().is_empty()) + .collect(); + if !dim_style_names + .iter() + .any(|name| name.eq_ignore_ascii_case(&style_name)) + { + dim_style_names.push(style_name.clone()); } + set_row_value( + &mut sections, + "tol_dim_style", + PropValue::Choice { + selected: style_name, + options: dim_style_names, + }, + ); + + let text_style_name = dov::handle( + &tol.common.extended_data, + dov::DIMTXSTY, + ) + .and_then(|handle| { + doc.text_styles + .iter() + .find(|entry| entry.handle == handle) + }) + .map(|entry| entry.name.clone()) + .or_else(|| style.map(|entry| entry.dimtxsty.clone())) + .unwrap_or_else(|| "Standard".to_string()); + let mut names = text_style_names.clone(); + if !names + .iter() + .any(|name| name.eq_ignore_ascii_case(&text_style_name)) + { + names.push(text_style_name.clone()); + } + set_row_value( + &mut sections, + "tol_text_style", + PropValue::Choice { + selected: text_style_name, + options: names, + }, + ); + + let height = dov::real( + &tol.common.extended_data, + dov::DIMTXT, + ) + .or_else(|| style.map(|entry| entry.dimtxt)) + .unwrap_or(tol.text_height); + set_row_value( + &mut sections, + "tol_text_height", + PropValue::EditText(format!("{height:.4}")), + ); } // MultiLeader: max points + segment-angle constraints // are MLeaderStyle settings, not stored on the entity. diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index c1f8b495..a66754cf 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -145,6 +145,13 @@ impl super::OpenCADStudio { if self.tabs[i].scene.is_layer_locked(target) { return iced::Task::none(); } + if matches!( + self.tabs[i].scene.document.get_entity(target), + Some(EntityType::Tolerance(_)) + ) { + self.open_tolerance_dialog(Some(target)); + return iced::Task::none(); + } // Snapshot what we need before borrowing `self` mutably to open. let Some(entity) = self.tabs[i].scene.document.get_entity(target) else { return iced::Task::none(); diff --git a/src/app/tolerance_dialog.rs b/src/app/tolerance_dialog.rs new file mode 100644 index 00000000..56e9b723 --- /dev/null +++ b/src/app/tolerance_dialog.rs @@ -0,0 +1,88 @@ +use acadrust::{EntityType, Handle}; + +impl super::OpenCADStudio { + pub(super) fn open_tolerance_dialog(&mut self, editing: Option) { + let i = self.active_tab; + if editing.is_some_and(|handle| self.tabs[i].scene.is_layer_locked(handle)) { + return; + } + let text = editing + .and_then(|handle| self.tabs[i].scene.document.get_entity(handle)) + .and_then(|entity| match entity { + EntityType::Tolerance(tolerance) => Some(tolerance.text.as_str()), + _ => None, + }) + .unwrap_or_default(); + self.geometric_tolerance = Some( + crate::ui::window::geometric_tolerance::State::from_text(editing, text), + ); + self.active_modal = Some(super::ModalKind::GeometricTolerance); + self.modal_offset = iced::Vector::ZERO; + self.modal_resize = iced::Vector::ZERO; + self.modal_content_size = None; + self.modal_drag_last = None; + self.modal_dragging = false; + self.modal_resizing = false; + } + + pub(super) fn apply_tolerance_dialog_edit(&mut self) -> bool { + let Some(state) = self.geometric_tolerance.as_ref() else { + return false; + }; + let Some(handle) = state.editing else { + return false; + }; + if !state.is_valid() || self.tabs[self.active_tab].scene.is_layer_locked(handle) { + return false; + } + let text = state.to_text(); + let i = self.active_tab; + let unchanged = self.tabs[i] + .scene + .document + .get_entity(handle) + .is_some_and(|entity| { + matches!(entity, EntityType::Tolerance(tolerance) if tolerance.text == text) + }); + if unchanged { + return true; + } + self.push_undo_snapshot(i, "TOLERANCE"); + let Some(EntityType::Tolerance(tolerance)) = + self.tabs[i].scene.document.get_entity_mut(handle) + else { + return false; + }; + tolerance.text = text; + self.tabs[i] + .scene + .bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + pub(super) fn begin_tolerance_placement(&mut self) -> bool { + let Some(state) = self.geometric_tolerance.take() else { + return false; + }; + if !state.is_valid() || state.editing.is_some() { + self.geometric_tolerance = Some(state); + return false; + } + let i = self.active_tab; + let mut command = + crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(state.to_text()); + use crate::command::CadCommand; + let plane = if self.tabs[i].editing_model_space() { + self.tabs[i].ucs_xform().working_plane() + } else { + crate::command::WorkingPlane::default() + }; + command.set_working_plane(plane); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + self.active_modal = None; + true + } +} diff --git a/src/app/update/command.rs b/src/app/update/command.rs index d6c48ab6..c097983b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1901,7 +1901,73 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); - if field.starts_with("dim_") { + if field == "tol_text_style" { + use crate::entities::dim_override as dov; + use acadrust::xdata::XDataValue; + let style_handle = self.tabs[i] + .scene + .document + .text_styles + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case(&value)) + .map(|entry| entry.handle); + if let Some(style_handle) = style_handle { + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) + || !matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(acadrust::EntityType::Tolerance(_)) + ) + { + continue; + } + dov::set( + &mut self.tabs[i].scene.document, + handle, + dov::DIMTXSTY, + Some(XDataValue::Handle(style_handle)), + ); + } + } + } else if field == "tol_dim_style" { + let style = self.tabs[i] + .scene + .document + .dim_styles + .iter() + .find(|entry| entry.name.eq_ignore_ascii_case(&value)) + .map(|entry| (entry.handle, entry.name.clone(), entry.annotative)); + if let Some((style_handle, style_name, annotative)) = style { + let scale = self.tabs[i].scene.creation_annotation_scale_handle(); + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + if let Some(acadrust::EntityType::Tolerance(tolerance)) = + self.tabs[i].scene.document.get_entity_mut(handle) + { + tolerance.dimension_style_handle = Some(style_handle); + tolerance.dimension_style_name = style_name.clone(); + } else { + continue; + } + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + handle, + annotative, + ); + if annotative { + if let Some(scale) = scale { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + handle, + scale, + ); + } + } + } + } + } else if field.starts_with("dim_") { for &handle in &handles { if self.tabs[i].scene.is_layer_locked(handle) { continue; @@ -2552,6 +2618,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { continue; } match field { + "tol_text_height" => { + use crate::entities::dim_override as dov; + let trimmed = val.trim(); + if trimmed.is_empty() { + dov::set( + &mut self.tabs[i].scene.document, + handle, + dov::DIMTXT, + None, + ); + } else if let Ok(height) = trimmed.parse::() { + if height > 0.0 { + dov::set( + &mut self.tabs[i].scene.document, + handle, + dov::DIMTXT, + Some(acadrust::xdata::XDataValue::Real(height)), + ); + } + } + } _ if field.starts_with("dim_") => { if matches!( self.tabs[i].scene.document.get_entity(handle), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a98bb676..7266953b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -195,6 +195,7 @@ impl OpenCADStudio { self.attr_editor_selected = 0; self.attr_editor_tab = crate::ui::window::attribute_editor::AttrTab::Attribute; } + Some(GeometricTolerance) => self.geometric_tolerance = None, // Closing (✕) discards edits made since the last Apply — matching the // style editors. Committing happens only through the Apply button. Some(Aliases) => self.alias_editor_rows.clear(), @@ -3706,6 +3707,40 @@ impl OpenCADStudio { self.refresh_properties(); Task::none() } + Message::ToleranceDialogField(field) => { + if let Some(state) = self.geometric_tolerance.as_mut() { + state.apply_field(field); + } + Task::none() + } + Message::ToleranceDialogToggle(toggle) => { + if let Some(state) = self.geometric_tolerance.as_mut() { + state.apply_toggle(toggle); + } + Task::none() + } + Message::ToleranceDialogApply => { + self.apply_tolerance_dialog_edit(); + Task::none() + } + Message::ToleranceDialogOk => { + let editing = self + .geometric_tolerance + .as_ref() + .and_then(|state| state.editing) + .is_some(); + if editing { + if self.apply_tolerance_dialog_edit() { + self.geometric_tolerance = None; + self.active_modal = None; + self.reset_modal_geometry(); + } + } else { + self.begin_tolerance_placement(); + self.reset_modal_geometry(); + } + Task::none() + } Message::SetLinearFormat(code) => { self.units_popup_open = false; let i = self.active_tab; diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0c52554a..0fa5e27a 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -21,6 +21,7 @@ impl OpenCADStudio { Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"), Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(), Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), + Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"), Some(K::Plot) => crate::tr!("modal", "plot"), @@ -234,6 +235,12 @@ impl OpenCADStudio { crate::ui::window::drawing_units::view_window(state, flow) }) } + super::super::ModalKind::GeometricTolerance => { + let state = self.geometric_tolerance.as_ref()?; + sized_flow(ex, 670, 530, |flow| { + crate::ui::window::geometric_tolerance::view_window(state, flow) + }) + } super::super::ModalKind::LayerStateManager => { let states = self.tabs[self.active_tab].scene.document.layer_states(); sized_flow( diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index f293e59f..6c64147b 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -4,7 +4,7 @@ use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke, RenderEntity, RenderObject}; -use crate::scene::model::object::{GripApply, GripDef, PropSection}; +use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::model::wire_model::SnapHint; use crate::scene::text::lff; use crate::scene::view::transform; @@ -144,51 +144,6 @@ fn symbol_font_switch(inner: &str) -> Option { tail.chars().next() } -/// Per-entity overrides of individual dimension-style variables, carried as -/// extended data under the `DSTYLE` application. -/// -/// An entity may keep its style yet override single variables on itself, as -/// `(variable group code, value)` pairs. Reading the style but ignoring these -/// draws the frame at the style's size rather than its own — which is how this -/// one, overriding its text height to a fraction of the style's, came out far -/// too large. -/// -/// Only the character height is read — it is the frame's single geometric -/// input (see `tessellate_tolerance`); every other variable is left to the style. -fn dstyle_overrides(tol: &Tolerance) -> Option { - // The only variable the frame is built from. - const DIMTXT: i16 = 140; - - use acadrust::xdata::XDataValue as V; - - // The record belongs to the shared "ACAD" application and names itself in - // its FIRST STRING VALUE. There is no record called "DSTYLE" — asking for - // one finds nothing and leaves every override silently unread. - let rec = tol.common.extended_data.get_record("ACAD")?; - let mut vals = rec.values.iter(); - match vals.next() { - Some(V::String(s)) if s == "DSTYLE" => {} - _ => return None, - } - - let mut txt = None; - let mut pending: Option = None; - for v in vals { - match v { - V::Integer16(code) => pending = Some(*code), - V::Real(value) | V::Distance(value) => { - if pending.take() == Some(DIMTXT) { - txt = Some(*value); - } - } - // Braces and anything else just delimit; a non-numeric value also - // ends the pair we were waiting on. - _ => pending = None, - } - } - txt -} - /// The tolerance's dimension style — by handle first, then by name. /// /// The order matters and is not interchangeable: a DWG records the style's @@ -196,7 +151,7 @@ fn dstyle_overrides(tol: &Tolerance) -> Option { /// leaves the handle empty. Matching on the name alone — the shape used /// elsewhere for entities that only ever carry one — would silently resolve /// every DWG-read tolerance to "Standard" and pick the wrong metrics. -fn resolve_dim_style<'a>( +pub(crate) fn resolve_dim_style<'a>( tol: &Tolerance, doc: &'a acadrust::CadDocument, ) -> Option<&'a acadrust::tables::DimStyle> { @@ -222,8 +177,20 @@ fn resolve_dim_style<'a>( /// /// This is where the pen LANDS, so it carries the font's letter spacing past /// the final glyph — which is what puts the gap between one run and the next. -fn run_advance(text: &str, font: &str, height: f32) -> f32 { - crate::entities::text_support::text_local_bounds(font, text, height, 1.0, 0.0) +fn run_advance( + text: &str, + font: &str, + height: f32, + width_factor: f32, + oblique: f32, +) -> f32 { + crate::entities::text_support::text_local_bounds( + font, + text, + height, + width_factor, + oblique, + ) .map(|b| b.advance) .unwrap_or(0.0) } @@ -232,8 +199,11 @@ fn run_advance(text: &str, font: &str, height: f32) -> f32 { /// /// Glyph geometry is authored against a 9-unit cap height, so a font's spacing /// scales with the character height like everything else. -fn letter_spacing(font: &str, height: f32) -> f32 { - crate::scene::text::font_face::Face::resolve(font).letter_spacing() * height / 9.0 +fn letter_spacing(font: &str, height: f32, width_factor: f32) -> f32 { + crate::scene::text::font_face::Face::resolve(font).letter_spacing() + * height + / 9.0 + * width_factor } /// How wide a compartment's content actually draws. @@ -243,15 +213,35 @@ fn letter_spacing(font: &str, height: f32) -> f32 { /// over-measures by exactly one spacing — the gaps between runs are real, the /// one hanging off the end is not — and every compartment came out that much /// too wide. -fn content_width(cell: &Cell, height: f32) -> f32 { +fn content_width( + cell: &Cell, + height: f32, + text_style: &crate::entities::text_support::ResolvedTextStyle, +) -> f32 { let Some(last) = cell.last() else { return 0.0; }; let pen: f32 = cell .iter() - .map(|r| run_advance(&r.text, r.font, height)) + .map(|run| { + let (font, width, oblique) = if run.font == SYMBOL_FONT { + (SYMBOL_FONT, 1.0, 0.0) + } else { + ( + text_style.font_name.as_str(), + text_style.width_factor, + text_style.oblique_angle, + ) + }; + run_advance(&run.text, font, height, width, oblique) + }) .sum(); - (pen - letter_spacing(last.font, height)).max(0.0) + let (last_font, last_width) = if last.font == SYMBOL_FONT { + (SYMBOL_FONT, 1.0) + } else { + (text_style.font_name.as_str(), text_style.width_factor) + }; + (pen - letter_spacing(last_font, height, last_width)).max(0.0) } /// One text run of a feature-control frame, ready to become a `TextStroke` @@ -261,11 +251,13 @@ fn content_width(cell: &Cell, height: f32) -> f32 { /// origin (no origin translation — the wire-builder adds the origin). struct TolCell { text: String, - font: &'static str, + font: String, origin: [f32; 2], strokes: Vec>, height: f32, rotation: f32, + width_factor: f32, + oblique: f32, } /// Tessellate a Tolerance entity's feature-control frame. @@ -296,17 +288,29 @@ fn tessellate_tolerance( // and the frame draws about ten times too small. Falling back to them only // when no style resolves keeps DXF-read entities working. let style = resolve_dim_style(tol, doc); - let scale = style - .map(|s| if s.dimscale > 1e-6 { s.dimscale } else { 1.0 }) + let xd = &tol.common.extended_data; + let scale = crate::entities::dim_override::real(xd, crate::entities::dim_override::DIMSCALE) + .or_else(|| style.map(|s| s.dimscale)) + .map(|value| if value > 1e-6 { value } else { 1.0 }) .unwrap_or(1.0); // The entity's own overrides win over its style; the style wins over the // entity's constructed defaults. - let h = dstyle_overrides(tol) + let h = crate::entities::dim_override::real(xd, crate::entities::dim_override::DIMTXT) .or(style.map(|s| s.dimtxt)) .map(|v| v * scale) .unwrap_or(tol.text_height) as f32; let h = if h > 1e-6 { h } else { 2.5_f32 }; + let text_style_name = crate::entities::dim_override::handle( + xd, + crate::entities::dim_override::DIMTXSTY, + ) + .and_then(|handle| doc.text_styles.iter().find(|entry| entry.handle == handle)) + .map(|entry| entry.name.as_str()) + .or_else(|| style.map(|style| style.dimtxsty.as_str())) + .unwrap_or("Standard"); + let text_style = crate::entities::text_support::resolve_text_style(text_style_name, doc); + // A compartment is a PROPORTION of the character height: it spans -h..+h // about the row's centreline, so it is exactly 2h tall and the h/2 margin // falls out of that rather than being an input. @@ -329,7 +333,9 @@ fn tessellate_tolerance( // (`len()`) would make each one two or three cells wide. // A compartment's runs come from different fonts, so each is measured in its // own before they are summed. - let cell_width = |cell: &Cell| -> f32 { (content_width(cell, h) + 2.0 * pad).max(min_cell_w) }; + let cell_width = |cell: &Cell| -> f32 { + (content_width(cell, h, &text_style) + 2.0 * pad).max(min_cell_w) + }; let row_widths: Vec> = rows .iter() .map(|row| row.iter().map(|c| cell_width(c)).collect()) @@ -399,11 +405,28 @@ fn tessellate_tolerance( // Centre the compartment's whole content, then lay its runs out // left to right — each in its own font, each advancing the pen // by what that font actually measures. - let mut run_x = cell_x + (cw - content_width(cell, h)) * 0.5; + let mut run_x = cell_x + (cw - content_width(cell, h, &text_style)) * 0.5; for run in cell { - let (text, font) = (run.text.clone(), run.font); + let text = run.text.clone(); + let (font, width_factor, oblique) = if run.font == SYMBOL_FONT { + (SYMBOL_FONT.to_string(), 1.0, 0.0) + } else { + ( + text_style.font_name.clone(), + text_style.width_factor, + text_style.oblique_angle, + ) + }; let (local_strokes, _) = - lff::tessellate_text_ex([0.0, 0.0], h, 0.0, 1.0, 0.0, font, &text); + lff::tessellate_text_ex( + [0.0, 0.0], + h, + 0.0, + width_factor, + oblique, + &font, + &text, + ); // Glyph polylines rotated about the run's origin (no origin // translation — the wire-builder adds `origin`). let strokes: Vec> = local_strokes @@ -411,7 +434,7 @@ fn tessellate_tolerance( .map(|pl| pl.into_iter().map(|[px, py]| rot(px, py)).collect()) .filter(|pl: &Vec<[f32; 2]>| !pl.is_empty()) .collect(); - let advance = run_advance(&text, font, h); + let advance = run_advance(&text, &font, h, width_factor, oblique); cells.push(TolCell { text, font, @@ -419,6 +442,8 @@ fn tessellate_tolerance( strokes, height: h, rotation: angle, + width_factor, + oblique, }); run_x += advance; } @@ -447,6 +472,45 @@ impl RenderConvertible for Tolerance { // Build the feature-control frame in local space; origin stored as f64. let (box_strokes, cells) = tessellate_tolerance(self, document); let ins = [self.insertion_point.x, self.insertion_point.y]; + let style = resolve_dim_style(self, document); + let xd = &self.common.extended_data; + let explicit_color = |index: Option| { + index.filter(|value| !matches!(value, 0 | 256)).map(|value| { + let [red, green, blue, _] = crate::scene::convert::tess_util::aci_to_rgba( + &acadrust::types::Color::from_index(value), + ); + [red, green, blue] + }) + }; + let color_value = |color: &acadrust::types::Color| { + let [red, green, blue, _] = + crate::scene::convert::tess_util::aci_to_rgba(color); + [red, green, blue] + }; + let frame_color = if let Some(index) = crate::entities::dim_override::int( + xd, + crate::entities::dim_override::DIMCLRD, + ) { + explicit_color(Some(index)) + } else if let Some(color) = + style.and_then(|entry| entry.dimclrd_true_color.as_ref()) + { + Some(color_value(color)) + } else { + explicit_color(style.map(|entry| entry.dimclrd)) + }; + let text_color = if let Some(index) = crate::entities::dim_override::int( + xd, + crate::entities::dim_override::DIMCLRT, + ) { + explicit_color(Some(index)) + } else if let Some(color) = + style.and_then(|entry| entry.dimclrt_true_color.as_ref()) + { + Some(color_value(color)) + } else { + explicit_color(style.map(|entry| entry.dimclrt)) + }; // Frame geometry first (run-less → always strokes; also the anchor // group so its origin = the insertion point), then one run-group per @@ -457,7 +521,7 @@ impl RenderConvertible for Tolerance { groups.push(TextStroke { strokes: box_strokes, origin: ins, - color: None, + color: frame_color, fill_tris: vec![], run: None, }); @@ -469,15 +533,15 @@ impl RenderConvertible for Tolerance { ins[0] + cell.origin[0] as f64, ins[1] + cell.origin[1] as f64, ], - color: None, + color: text_color, fill_tris: vec![], run: Some(GlyphRun { text: cell.text, - font: cell.font.to_string(), + font: cell.font, height: cell.height, rotation: cell.rotation, - width_factor: 1.0, - oblique: 0.0, + width_factor: cell.width_factor, + oblique: cell.oblique, // `tracking` scales the font's own letter spacing, so 0 // collapses the gap between glyphs and the characters run // together. Every other text-bearing entity passes 1.0, and @@ -539,12 +603,19 @@ impl Grippable for Tolerance { // ── PropertyEditable ────────────────────────────────────────────────────────── impl PropertyEditable for Tolerance { - fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { + fn geometry_properties(&self, text_style_names: &[String]) -> Vec { vec![ PropSection { title: t!("Text").into_owned(), props: vec![ - ro(t!("Text style").as_ref(), "tol_text_style", String::new()), + Property { + label: t!("Text style").into_owned(), + field: "tol_text_style", + value: PropValue::Choice { + selected: String::new(), + options: text_style_names.to_vec(), + }, + }, edit(t!("Text height").as_ref(), "tol_text_height", self.text_height), ], }, @@ -585,9 +656,29 @@ impl PropertyEditable for Tolerance { "tol_iy" => self.insertion_point.y = v, "tol_iz" => self.insertion_point.z = v, "tol_text_height" if v > 0.0 => self.text_height = v, - "tol_dir_x" => self.direction.x = v, - "tol_dir_y" => self.direction.y = v, - "tol_dir_z" => self.direction.z = v, + "tol_dir_x" | "tol_dir_y" | "tol_dir_z" => { + let mut candidate = self.direction; + match field { + "tol_dir_x" => candidate.x = v, + "tol_dir_y" => candidate.y = v, + _ => candidate.z = v, + } + let direction_len = + (candidate.x * candidate.x + candidate.y * candidate.y + candidate.z * candidate.z) + .sqrt(); + let normal_len = + (self.normal.x * self.normal.x + self.normal.y * self.normal.y + self.normal.z * self.normal.z) + .sqrt(); + let dot = candidate.x * self.normal.x + + candidate.y * self.normal.y + + candidate.z * self.normal.z; + if direction_len > 1.0e-9 + && (normal_len <= 1.0e-9 + || dot.abs() <= direction_len * normal_len * 1.0e-8) + { + self.direction = candidate; + } + } _ => {} } } @@ -923,9 +1014,26 @@ mod tests { fn compartments_are_measured_by_their_ink_not_the_trailing_pen_gap() { // Cap height, so glyph units and world units line up. let h = 9.0_f32; + let document = acadrust::CadDocument::new(); + let text_style = crate::entities::text_support::resolve_text_style("Standard", &document); for src in ["{\\Fgdt;r}", "{\\Fgdt;n}tol{\\Fgdt;m}", "1{\\Fgdt;m}", "A"] { let cell = parse_cell(src); - let pen: f32 = cell.iter().map(|r| run_advance(&r.text, r.font, h)).sum(); + let pen: f32 = cell + .iter() + .map(|run| { + if run.font == SYMBOL_FONT { + run_advance(&run.text, SYMBOL_FONT, h, 1.0, 0.0) + } else { + run_advance( + &run.text, + &text_style.font_name, + h, + text_style.width_factor, + text_style.oblique_angle, + ) + } + }) + .sum(); let ink: f32 = cell .iter() .map(|r| { @@ -934,11 +1042,17 @@ mod tests { .unwrap_or(0.0) }) .sum(); - let got = content_width(&cell, h); + let got = content_width(&cell, h, &text_style); // Exactly one trailing gap comes off — the gaps BETWEEN runs are // real spacing and must stay. - let spacing = letter_spacing(cell.last().unwrap().font, h); + let last = cell.last().unwrap(); + let (font, width) = if last.font == SYMBOL_FONT { + (SYMBOL_FONT, 1.0) + } else { + (text_style.font_name.as_str(), text_style.width_factor) + }; + let spacing = letter_spacing(font, h, width); assert!( (got - (pen - spacing)).abs() < 1e-3, "{src:?}: content {got} should be pen {pen} less one {spacing} gap" diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index d27927ee..911d9dab 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -1,8 +1,6 @@ // TOLERANCE command — place a GD&T (geometric dimensioning & tolerancing) frame. // -// Workflow: -// 1. Text: Enter tolerance string (e.g. "%%v0.05|A" or plain text) -// 2. Point: Click insertion point +// The structured editor prepares the frame; this command places it. use acadrust::entities::Tolerance; use acadrust::types::Vector3; @@ -25,20 +23,15 @@ pub fn tool() -> ToolDef { } } -enum Step { - Text, - Insertion { text: String }, -} - pub struct ToleranceCommand { - step: Step, + text: String, plane: WorkingPlane, } impl ToleranceCommand { - pub fn new() -> Self { + pub fn with_text(text: String) -> Self { Self { - step: Step::Text, + text, plane: WorkingPlane::default(), } } @@ -54,36 +47,18 @@ impl CadCommand for ToleranceCommand { } fn prompt(&self) -> String { - match &self.step { - Step::Text => t!("TOLERANCE Enter tolerance text:").into_owned(), - Step::Insertion { text } => { - t!("TOLERANCE Specify insertion point [%{text}]:", text = text).into_owned() - } - } + t!("TOLERANCE Specify insertion point:").into_owned() } fn wants_text_input(&self) -> bool { - matches!(self.step, Step::Text) - } - - fn on_text_input(&mut self, text: &str) -> Option { - let t = text.trim().to_string(); - if t.is_empty() { - return Some(CmdResult::Cancel); - } - self.step = Step::Insertion { text: t }; - Some(CmdResult::NeedPoint) + false } fn on_point(&mut self, pt: DVec3) -> CmdResult { - if let Step::Insertion { text } = &self.step { - let point = self.plane.to_local(pt); - let ins = Vector3::new(point.x, point.y, point.z); - let tol = Tolerance::with_text(ins, text.clone()); - CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol))) - } else { - CmdResult::NeedPoint - } + let point = self.plane.to_local(pt); + let ins = Vector3::new(point.x, point.y, point.z); + let tol = Tolerance::with_text(ins, self.text.clone()); + CmdResult::CommitAndExit(self.plane.place_entity(EntityType::Tolerance(tol))) } fn on_enter(&mut self) -> CmdResult { @@ -91,17 +66,49 @@ impl CadCommand for ToleranceCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - if !matches!(self.step, Step::Insertion { .. }) { - return None; + let normalized = self + .text + .replace("^J", "\n") + .replace("\\P", "\n") + .replace("\r\n", "\n") + .replace('\r', "\n"); + let rows: Vec> = normalized + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + let mut cells: Vec<&str> = line.split("%%v").collect(); + while cells.last().is_some_and(|cell| cell.trim().is_empty()) { + cells.pop(); + } + cells + }) + .filter(|cells| !cells.is_empty()) + .collect(); + let row_height = 0.35; + let cell_width = 0.5; + let mut points = Vec::new(); + let mut segment = |a: DVec3, b: DVec3| { + if !points.is_empty() { + points.push(DVec3::splat(f64::NAN)); + } + points.push(a); + points.push(b); + }; + for (row_index, cells) in rows.iter().enumerate() { + let count = cells.len().max(1); + let y0 = -row_height * (row_index as f64 + 0.5); + let y1 = y0 + row_height; + let x1 = cell_width * count as f64; + let p = |x: f64, y: f64| pt + self.plane.x * x + self.plane.y * y; + segment(p(0.0, y0), p(x1, y0)); + segment(p(x1, y0), p(x1, y1)); + segment(p(x1, y1), p(0.0, y1)); + segment(p(0.0, y1), p(0.0, y0)); + for index in 1..count { + let x = cell_width * index as f64; + segment(p(x, y0), p(x, y1)); + } } - let d = 0.15; - let points = [ - pt - self.plane.x * d, - pt + self.plane.x * d, - DVec3::splat(f64::NAN), - pt - self.plane.y * d, - pt + self.plane.y * d, - ]; Some(WireModel { point_marker: None, taper_widths: Vec::new(), @@ -119,10 +126,7 @@ impl CadCommand for ToleranceCommand { dash_align_end: None, text_verts: Vec::new(), name: "tolerance_preview".into(), - points: points - .iter() - .map(|point| point.as_vec3().to_array()) - .collect(), + points: points.iter().map(|point| point.as_vec3().to_array()).collect(), points_low: Vec::new(), color: WireModel::CYAN, selected: false, diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index fe199e88..2dbe971e 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) { + if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) + || matches!(entity, EntityType::Tolerance(_)) + { section.props.retain(|prop| prop.field != "handle"); } diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index d249f80b..5ce5e56d 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -416,6 +416,7 @@ fn tessellate_entity_inner( | EntityType::MText(_) | EntityType::Dimension(_) | EntityType::MultiLeader(_) + | EntityType::Tolerance(_) ) { crate::scene::annotative::effective_annotation_scale_for( document, diff --git a/src/ui/window/geometric_tolerance.rs b/src/ui/window/geometric_tolerance.rs new file mode 100644 index 00000000..214f4abd --- /dev/null +++ b/src/ui/window/geometric_tolerance.rs @@ -0,0 +1,479 @@ +//! Structured feature-control-frame editor. +//! +//! The entity stores a compact escape string, but users work with named +//! symbols, tolerance compartments and datum references. This dialog keeps +//! that storage detail behind typed controls and can also reopen existing +//! frames without losing their compartment layout. + +use std::fmt; + +use acadrust::Handle; +use iced::widget::{button, checkbox, column, container, row, text, text_input, Space}; +use iced::{Border, Element, Fill, Length, Theme}; + +use crate::app::Message; +use crate::t; + +const EMPTY: &str = ""; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ToleranceEntry { + pub diameter: bool, + pub value: String, + pub material: String, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DatumEntry { + pub value: String, + pub material: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct State { + pub editing: Option, + pub symbol: String, + pub tolerances: [ToleranceEntry; 2], + pub datums: [DatumEntry; 3], + pub projected_height: String, + pub projected_zone: bool, + pub datum_identifier: String, +} + +impl Default for State { + fn default() -> Self { + Self { + editing: None, + symbol: String::new(), + tolerances: std::array::from_fn(|_| ToleranceEntry::default()), + datums: std::array::from_fn(|_| DatumEntry::default()), + projected_height: String::new(), + projected_zone: false, + datum_identifier: String::new(), + } + } +} + +#[derive(Clone, Debug)] +pub enum Field { + Symbol(String), + ToleranceValue(usize, String), + ToleranceMaterial(usize, String), + DatumValue(usize, String), + DatumMaterial(usize, String), + ProjectedHeight(String), + DatumIdentifier(String), +} + +#[derive(Clone, Debug)] +pub enum Toggle { + Diameter(usize, bool), + ProjectedZone(bool), +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Choice { + code: &'static str, + label: &'static str, +} + +impl fmt::Display for Choice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(crate::i18n::translate(self.label).as_ref()) + } +} + +const SYMBOLS: [(&str, &str); 15] = [ + ("", "None"), + ("u", "Straightness"), + ("c", "Flatness"), + ("e", "Circularity"), + ("g", "Cylindricity"), + ("d", "Profile of a surface"), + ("k", "Profile of a line"), + ("j", "Position"), + ("r", "Concentricity"), + ("i", "Symmetry"), + ("f", "Parallelism"), + ("b", "Perpendicularity"), + ("a", "Angularity"), + ("h", "Circular runout"), + ("t", "Total runout"), +]; + +const MATERIALS: [(&str, &str); 4] = [ + ("", "None"), + ("m", "Maximum material condition"), + ("l", "Least material condition"), + ("s", "Regardless of feature size"), +]; + +fn choices(values: &'static [(&'static str, &'static str)]) -> Vec { + values + .iter() + .map(|(code, label)| Choice { code, label }) + .collect() +} + +fn selected(values: &'static [(&'static str, &'static str)], code: &str) -> Option { + values + .iter() + .find(|(value, _)| value.eq_ignore_ascii_case(code)) + .map(|(code, label)| Choice { code, label }) +} + +fn escape(code: &str) -> String { + if code.is_empty() { + String::new() + } else { + format!("{{\\Fgdt;{code}}}") + } +} + +fn strip_escape(input: &str, code: &str) -> Option { + let marker = escape(code); + input + .strip_suffix(&marker) + .map(std::string::ToString::to_string) +} + +fn parse_material(input: &str) -> (String, String) { + for code in ["m", "l", "s"] { + if let Some(value) = strip_escape(input, code) { + return (value, code.to_string()); + } + } + (input.to_string(), String::new()) +} + +impl State { + pub fn from_text(editing: Option, raw: &str) -> Self { + let mut state = Self { + editing, + ..Self::default() + }; + let normalized = raw + .replace("^J", "\n") + .replace("\\P", "\n") + .replace("\r\n", "\n") + .replace('\r', "\n"); + let mut lines = normalized.lines(); + + if let Some(frame) = lines.next() { + let cells: Vec<&str> = frame.split("%%v").collect(); + if let Some(cell) = cells.first() { + state.symbol = SYMBOLS + .iter() + .find_map(|(code, _)| { + (!code.is_empty() && cell.contains(&escape(code))).then(|| code.to_string()) + }) + .unwrap_or_default(); + } + for index in 0..2 { + if let Some(cell) = cells.get(index + 1) { + let (cell, diameter) = if let Some(rest) = cell.strip_prefix(&escape("n")) { + (rest, true) + } else { + (*cell, false) + }; + let (value, material) = parse_material(cell); + state.tolerances[index] = ToleranceEntry { + diameter, + value, + material, + }; + } + } + for index in 0..3 { + if let Some(cell) = cells.get(index + 3) { + let (value, material) = parse_material(cell); + state.datums[index] = DatumEntry { value, material }; + } + } + if cells.len() == 1 + && state.symbol.is_empty() + && !frame.trim().is_empty() + { + state.tolerances[0].value = frame.trim().to_string(); + } + } + + if let Some(projected) = lines.next() { + if let Some(value) = strip_escape(projected, "p") { + state.projected_height = value; + state.projected_zone = true; + } else { + state.projected_height = projected.to_string(); + } + } + if let Some(identifier) = lines.next() { + state.datum_identifier = identifier.to_string(); + } + state + } + + pub fn apply_field(&mut self, field: Field) { + match field { + Field::Symbol(value) => self.symbol = value, + Field::ToleranceValue(index, value) if index < 2 => { + self.tolerances[index].value = value + } + Field::ToleranceMaterial(index, value) if index < 2 => { + self.tolerances[index].material = value + } + Field::DatumValue(index, value) if index < 3 => self.datums[index].value = value, + Field::DatumMaterial(index, value) if index < 3 => { + self.datums[index].material = value + } + Field::ProjectedHeight(value) => self.projected_height = value, + Field::DatumIdentifier(value) => self.datum_identifier = value, + _ => {} + } + } + + pub fn apply_toggle(&mut self, toggle: Toggle) { + match toggle { + Toggle::Diameter(index, value) if index < 2 => { + self.tolerances[index].diameter = value + } + Toggle::ProjectedZone(value) => self.projected_zone = value, + _ => {} + } + } + + pub fn is_valid(&self) -> bool { + !self.symbol.is_empty() + || self.tolerances.iter().any(|entry| !entry.value.trim().is_empty()) + || self.datums.iter().any(|entry| !entry.value.trim().is_empty()) + || !self.projected_height.trim().is_empty() + || !self.datum_identifier.trim().is_empty() + } + + pub fn to_text(&self) -> String { + let mut cells = Vec::with_capacity(6); + cells.push(escape(&self.symbol)); + for entry in &self.tolerances { + let mut value = String::new(); + if entry.diameter && !entry.value.trim().is_empty() { + value.push_str(&escape("n")); + } + value.push_str(entry.value.trim()); + if !entry.value.trim().is_empty() { + value.push_str(&escape(&entry.material)); + } + cells.push(value); + } + for entry in &self.datums { + let mut value = entry.value.trim().to_string(); + if !value.is_empty() { + value.push_str(&escape(&entry.material)); + } + cells.push(value); + } + while cells.last().is_some_and(String::is_empty) { + cells.pop(); + } + let mut rows = vec![cells.join("%%v")]; + if !self.projected_height.trim().is_empty() || self.projected_zone { + let mut projected = self.projected_height.trim().to_string(); + if self.projected_zone { + projected.push_str(&escape("p")); + } + rows.push(projected); + } + if !self.datum_identifier.trim().is_empty() { + if rows.len() == 1 { + rows.push(String::new()); + } + rows.push(self.datum_identifier.trim().to_string()); + } + rows.join("\n") + } +} + +fn muted(theme: &Theme) -> iced::widget::text::Style { + iced::widget::text::Style { + color: Some(theme.palette().background.base.text.scale_alpha(0.65)), + } +} + +fn panel<'a>(title: String, body: Element<'a, Message>) -> Element<'a, Message> { + container(column![text(title).size(11).style(muted), body].spacing(6)) + .padding(8) + .width(Fill) + .style(|theme: &Theme| container::Style { + border: Border { + width: 1.0, + radius: 4.0.into(), + color: theme.palette().background.strong.color, + }, + ..Default::default() + }) + .into() +} + +fn material_picker<'a>(index: usize, datum: bool, code: &str) -> Element<'a, Message> { + let options = choices(&MATERIALS); + let selected = selected(&MATERIALS, code); + iced::widget::pick_list(selected, options, |choice| choice.to_string()) + .on_select(move |choice| { + if datum { + Message::ToleranceDialogField(Field::DatumMaterial( + index, + choice.code.to_string(), + )) + } else { + Message::ToleranceDialogField(Field::ToleranceMaterial( + index, + choice.code.to_string(), + )) + } + }) + .text_size(11) + .padding([3, 6]) + .width(Length::Fixed(190.0)) + .into() +} + +pub fn view_window<'a>( + state: &State, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let symbol_options = choices(&SYMBOLS); + let symbol_selected = selected(&SYMBOLS, &state.symbol); + let symbol = panel( + t!("Geometric characteristic").into_owned(), + iced::widget::pick_list(symbol_selected, symbol_options, |choice| choice.to_string()) + .on_select(|choice| { + Message::ToleranceDialogField(Field::Symbol(choice.code.to_string())) + }) + .text_size(12) + .padding([4, 6]) + .width(Fill) + .into(), + ); + + let tolerance_rows = state.tolerances.iter().enumerate().fold( + column![].spacing(6), + |column, (index, entry)| { + column.push( + row![ + text(format!("{} {}", t!("Tolerance"), index + 1)) + .size(11) + .style(muted) + .width(Length::Fixed(82.0)), + checkbox(entry.diameter) + .on_toggle(move |value| Message::ToleranceDialogToggle( + Toggle::Diameter(index, value) + )) + .size(14), + text(t!("Diameter")).size(11).width(Length::Fixed(62.0)), + text_input(EMPTY, &entry.value) + .on_input(move |value| Message::ToleranceDialogField( + Field::ToleranceValue(index, value) + )) + .size(12) + .padding([3, 6]) + .width(Length::Fixed(125.0)), + material_picker(index, false, &entry.material), + ] + .spacing(6) + .align_y(iced::Center), + ) + }, + ); + let tolerances = panel(t!("Tolerance values").into_owned(), tolerance_rows.into()); + + let datum_rows = state.datums.iter().enumerate().fold( + column![].spacing(6), + |column, (index, entry)| { + column.push( + row![ + text(format!("{} {}", t!("Datum"), index + 1)) + .size(11) + .style(muted) + .width(Length::Fixed(82.0)), + text_input(EMPTY, &entry.value) + .on_input(move |value| Message::ToleranceDialogField( + Field::DatumValue(index, value) + )) + .size(12) + .padding([3, 6]) + .width(Length::Fixed(212.0)), + material_picker(index, true, &entry.material), + ] + .spacing(6) + .align_y(iced::Center), + ) + }, + ); + let datums = panel(t!("Datum references").into_owned(), datum_rows.into()); + + let additions = panel( + t!("Additional information").into_owned(), + column![ + row![ + text(t!("Projected height")) + .size(11) + .style(muted) + .width(Length::Fixed(112.0)), + text_input(EMPTY, &state.projected_height) + .on_input(|value| Message::ToleranceDialogField(Field::ProjectedHeight(value))) + .size(12) + .padding([3, 6]) + .width(Length::Fixed(125.0)), + checkbox(state.projected_zone) + .on_toggle(|value| Message::ToleranceDialogToggle( + Toggle::ProjectedZone(value) + )) + .size(14), + text(t!("Projected tolerance zone")).size(11), + ] + .spacing(6) + .align_y(iced::Center), + row![ + text(t!("Datum identifier")) + .size(11) + .style(muted) + .width(Length::Fixed(112.0)), + text_input(EMPTY, &state.datum_identifier) + .on_input(|value| Message::ToleranceDialogField(Field::DatumIdentifier(value))) + .size(12) + .padding([3, 6]) + .width(Length::Fixed(212.0)), + ] + .spacing(6) + .align_y(iced::Center), + ] + .spacing(6) + .into(), + ); + + let mut actions = row![ + Space::new().width(Fill), + button(text(t!("Cancel")).size(12)) + .on_press(Message::CloseModal) + .padding([4, 12]), + ] + .spacing(6) + .align_y(iced::Center); + if state.editing.is_some() { + actions = actions.push( + button(text(t!("Apply")).size(12)) + .on_press_maybe(state.is_valid().then_some(Message::ToleranceDialogApply)) + .padding([4, 12]), + ); + } + actions = actions.push( + button(text(t!("OK")).size(12)) + .on_press_maybe(state.is_valid().then_some(Message::ToleranceDialogOk)) + .style(button::primary) + .padding([4, 12]), + ); + + column![symbol, tolerances, datums, additions, actions] + .spacing(8) + .padding(10) + .width(sizing.width) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index e1ae0b22..c4e345c8 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -3,6 +3,7 @@ pub mod block_palette; pub mod layout_manager; pub mod layer_state_manager; pub mod drawing_units; +pub mod geometric_tolerance; pub mod drafting_settings; pub mod layer_translator; pub mod plot; From aa552d48e45e0935015bc83d9432eaedd1d35647 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:18:43 +0300 Subject: [PATCH 83/98] Align tolerance property rows --- src/entities/tolerance.rs | 46 ++++++++------------------------------- 1 file changed, 9 insertions(+), 37 deletions(-) diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index 6c64147b..a1324e1b 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -629,20 +629,15 @@ impl PropertyEditable for Tolerance { }, PropSection { title: t!("Misc").into_owned(), - props: vec![ - ro( - t!("Dimension style").as_ref(), - "tol_dim_style", - if self.dimension_style_name.is_empty() { - "(default)".to_string() - } else { - self.dimension_style_name.clone() - }, - ), - edit(t!("Direction X").as_ref(), "tol_dir_x", self.direction.x), - edit(t!("Direction Y").as_ref(), "tol_dir_y", self.direction.y), - edit(t!("Direction Z").as_ref(), "tol_dir_z", self.direction.z), - ], + props: vec![ro( + t!("Dim style").as_ref(), + "tol_dim_style", + if self.dimension_style_name.is_empty() { + "(default)".to_string() + } else { + self.dimension_style_name.clone() + }, + )], }, ] } @@ -656,29 +651,6 @@ impl PropertyEditable for Tolerance { "tol_iy" => self.insertion_point.y = v, "tol_iz" => self.insertion_point.z = v, "tol_text_height" if v > 0.0 => self.text_height = v, - "tol_dir_x" | "tol_dir_y" | "tol_dir_z" => { - let mut candidate = self.direction; - match field { - "tol_dir_x" => candidate.x = v, - "tol_dir_y" => candidate.y = v, - _ => candidate.z = v, - } - let direction_len = - (candidate.x * candidate.x + candidate.y * candidate.y + candidate.z * candidate.z) - .sqrt(); - let normal_len = - (self.normal.x * self.normal.x + self.normal.y * self.normal.y + self.normal.z * self.normal.z) - .sqrt(); - let dot = candidate.x * self.normal.x - + candidate.y * self.normal.y - + candidate.z * self.normal.z; - if direction_len > 1.0e-9 - && (normal_len <= 1.0e-9 - || dot.abs() <= direction_len * normal_len * 1.0e-8) - { - self.direction = candidate; - } - } _ => {} } } From d039bcd4f968095892a1278f2c25d5b06377827d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:19:01 +0300 Subject: [PATCH 84/98] Expose tolerance annotation properties --- src/app/properties.rs | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index d4e51d93..68b6d3f5 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1772,7 +1772,7 @@ impl OpenCADStudio { { // Which entities show an Annotative row, the field it uses, // and — for those that don't already carry the row - // (dimension / table) — the existing field to insert it + // (dimension / table / tolerance) — the existing field to insert it // after. MLeader uses its editable toggle field. let anno: Option<(&str, Option<&str>)> = match entity { acadrust::EntityType::Text(_) @@ -1786,14 +1786,21 @@ impl OpenCADStudio { acadrust::EntityType::Dimension(_) => { Some(("annotative", Some("style_name"))) } + acadrust::EntityType::Tolerance(_) => { + Some(("annotative", Some("tol_dim_style"))) + } acadrust::EntityType::Table(_) => { Some(("annotative", Some("tbl_style_handle"))) } _ => None, }; if let Some((anno_field, insert_after)) = anno { - let is_anno = crate::scene::annotative::is_annotative(doc, entity); - // Dimensions/tables carry no Annotative row yet — add one + let is_anno = crate::scene::annotative::is_annotative(doc, entity) + || (matches!(entity, acadrust::EntityType::Tolerance(_)) + && crate::scene::annotative::annotation_style_is_annotative( + doc, entity, + )); + // Dimensions/tables/tolerances carry no Annotative row yet — add one // right after their style row. if let Some(anchor) = insert_after { insert_row_after( From 66bf4b1d5fd92505444c65cf390a4e72fc071c5a Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:19:24 +0300 Subject: [PATCH 85/98] Honor fixed tolerance text styles --- src/app/properties.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/app/properties.rs b/src/app/properties.rs index 68b6d3f5..5d55c28d 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1355,6 +1355,13 @@ impl OpenCADStudio { .map(|entry| entry.name.clone()) .or_else(|| style.map(|entry| entry.dimtxsty.clone())) .unwrap_or_else(|| "Standard".to_string()); + let text_height_editable = doc + .text_styles + .iter() + .find(|entry| { + entry.name.eq_ignore_ascii_case(&text_style_name) + }) + .is_none_or(|entry| !entry.has_fixed_height()); let mut names = text_style_names.clone(); if !names .iter() @@ -1380,7 +1387,11 @@ impl OpenCADStudio { set_row_value( &mut sections, "tol_text_height", - PropValue::EditText(format!("{height:.4}")), + if text_height_editable { + PropValue::EditText(format!("{height:.4}")) + } else { + PropValue::ReadOnly(format!("{height:.4}")) + }, ); } // MultiLeader: max points + segment-angle constraints From bbd58eb9f217d27a705728ea1f74b3e14b0228a1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 14:23:08 +0300 Subject: [PATCH 86/98] fix(layout): identify sheet viewports reliably --- src/app/commands/layers.rs | 35 +----- src/app/update/file.rs | 9 +- src/app/update/viewport.rs | 5 +- src/scene/convert/tess.rs | 2 +- src/scene/convert/tessellate.rs | 19 +--- src/scene/mod.rs | 183 ++++++++++++++++++-------------- src/scene/paper.rs | 2 +- 7 files changed, 124 insertions(+), 131 deletions(-) diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index a0220654..9dfc1e77 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -827,30 +827,8 @@ impl OpenCADStudio { } } - // A viewport frames the model, so its framing is measured in model - // units and has to follow them. Left alone, every layout would suddenly - // look at a region a thousand times too large. The paper side of the - // ratio is untouched — the sheet is still the same sheet — so the ratio - // itself moves the other way. - // - // `transform_viewport` covers the rectangle and the target but not the - // framing, so the framing is done here for every viewport, and the - // target only for the ones the scale above did not already reach. - // Not every viewport frames the model, though. Each layout's sheet - // viewport frames the sheet — its view is the paper, measured in paper - // units — so scaling it by the model's factor would leave the drawing - // frame the same size as before while the view of it changed by a - // thousand, which is the boundary appearing to break. - let sheets: std::collections::HashSet = self.tabs[i] - .scene - .document - .objects - .values() - .filter_map(|object| match object { - acadrust::objects::ObjectType::Layout(layout) => Some(layout.viewport), - _ => None, - }) - .collect(); + // Model framing follows model units; sheet framing stays in paper units. + let sheets = self.tabs[i].scene.sheet_viewport_handles(); let scaled: std::collections::HashSet = handles.iter().copied().collect(); let mut reframed = 0usize; for entity in self.tabs[i].scene.document.entities_mut() { @@ -858,14 +836,7 @@ impl OpenCADStudio { let acadrust::entities::EntityType::Viewport(vp) = entity else { continue; }; - // Only a paper-space viewport can be a sheet — the scale above - // reached everything in model space, and what it reached frames the - // model by definition. Among the rest, the layout names its sheet - // outright, and a file that arrives without that link still gives - // itself away by sitting at the paper origin, where only the sheet - // sits. - let is_sheet = !scaled.contains(&handle) - && (sheets.contains(&handle) || !crate::scene::Scene::is_content_viewport(vp)); + let is_sheet = !scaled.contains(&handle) && sheets.contains(&handle); if is_sheet { continue; } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1f77d4bf..bbf906ea 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -204,7 +204,14 @@ fn plot_scene_content( || !crate::scene::Scene::handle_from_wire_name(&wire.name) .and_then(|handle| scene.document.get_entity(handle)) .is_some_and(|entity| { - matches!(entity, acadrust::EntityType::Viewport(viewport) if crate::scene::Scene::is_content_viewport(viewport)) + matches!( + entity, + acadrust::EntityType::Viewport(viewport) + if !crate::scene::Scene::is_sheet_viewport( + &scene.document, + viewport, + ) + ) })) }); model_wires.retain(|wire| wire.plot_visible); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index a527895b..34eddd24 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4620,7 +4620,10 @@ impl OpenCADStudio { if let Some(AcadEntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity(h) { - if Scene::is_content_viewport(vp) { + if !Scene::is_sheet_viewport( + &self.tabs[i].scene.document, + vp, + ) { Some(h) } else { None diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index d249f80b..dfc58a1d 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -527,7 +527,7 @@ fn tessellate_entity_inner( if let EntityType::Viewport(vp) = e { // The sheet viewport (overall/id=1) is never shown — it represents the // paper boundary, not a user-defined content window. - if !Scene::is_content_viewport(vp) { + if Scene::is_sheet_viewport(document, vp) { return vec![]; } let is_active = active_viewport == Some(h); diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d474c5f1..c6e5d34a 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -1718,24 +1718,9 @@ pub fn tessellate( // f64 for the WireModel's double-single-era snap buffer. let snap_pts: Vec<(glam::DVec3, SnapHint)> = snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect(); - // A paper-space viewport is a window, not a wireframe: give it an interior - // pick surface so a click anywhere inside the frame selects it. Ranked - // below edge and fill hits, so content drawn inside still wins the click. - // The sheet ("overall") viewport is the layout's own invisible camera - // frame covering the whole page — never pickable, or it would swallow - // every click over the real viewports beneath it. It is identified by the - // Layout object's viewport link (authoritative — DWG files carry id = 0 - // and this file class centres the sheet viewport off-origin, so neither - // the id nor the geometry heuristic alone is reliable), with - // `is_content_viewport` as the fallback classifier. - let is_sheet_vp = |vp: &acadrust::entities::Viewport| { - let h = vp.common.handle; - document.objects.values().any(|obj| { - matches!(obj, acadrust::objects::ObjectType::Layout(l) if l.viewport == h) - }) || !crate::scene::Scene::is_content_viewport(vp) - }; + // Paper viewports are pickable inside their frames; the sheet viewport is not. let (pick_tris, pick_tris_low) = match entity { - EntityType::Viewport(vp) if !is_sheet_vp(vp) => { + EntityType::Viewport(vp) if !crate::scene::Scene::is_sheet_viewport(document, vp) => { if let Some(polygon) = clipped_viewport_polygon.as_ref() { points_to_ds(crate::entities::mesh::triangulate_planar(polygon)) } else { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 313058bc..37c3f209 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -2998,32 +2998,96 @@ impl Scene { crate::io::set_saved_active_layout(&mut self.document, &self.current_layout); } - /// Returns true if this viewport should display model-space content - /// (i.e. it is a user viewport, not the sheet/overall viewport). - /// - /// Rules: - /// - id=1 → always the sheet viewport → false - /// - id≥2 → always a user viewport → true - /// - id=0 or id<0 (DWG reader omits the id; some DXF exporters write -1): - /// use geometry: the sheet viewport is centred at the paper origin (0,0) - /// with scale≈1.0 (view_height ≈ paper-space height). - pub fn is_content_viewport(vp: &acadrust::entities::Viewport) -> bool { - if vp.id == 1 { - return false; + pub(crate) fn layout_sheet_viewport_handle( + document: &acadrust::CadDocument, + layout: &acadrust::objects::Layout, + ) -> Handle { + let owned_viewport = |handle| match document.get_entity(handle) { + Some(EntityType::Viewport(vp)) if vp.common.owner_handle == layout.block_record => { + Some(vp) + } + _ => None, + }; + + let listed = layout + .viewports + .iter() + .copied() + .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); + if let Some((handle, _)) = listed.clone().find(|(_, vp)| vp.id == 1) { + return handle; } - if vp.id > 1 { + if let Some((handle, _)) = listed.into_iter().next() { + return handle; + } + + let block_handles = document + .block_records + .iter() + .find(|block| block.handle == layout.block_record) + .map(|block| block.entity_handles.as_slice()) + .unwrap_or_default(); + let block_viewports = block_handles + .iter() + .copied() + .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); + if let Some((handle, _)) = block_viewports.clone().find(|(_, vp)| vp.id == 1) { + return handle; + } + if let Some((handle, _)) = block_viewports.into_iter().next() { + return handle; + } + + document + .entities() + .filter_map(|entity| match entity { + EntityType::Viewport(vp) if vp.common.owner_handle == layout.block_record => { + Some(( + vp.common.handle, + vp.id == 1, + vp.width.abs() * vp.height.abs(), + )) + } + _ => None, + }) + .max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.2.total_cmp(&b.2))) + .map(|(handle, _, _)| handle) + .unwrap_or(Handle::NULL) + } + + pub(crate) fn is_sheet_viewport( + document: &acadrust::CadDocument, + vp: &acadrust::entities::Viewport, + ) -> bool { + if vp.id == 1 { return true; } - // id ≤ 0: DWG files never write group-code 69 (viewport id), so all - // viewports arrive with id=0. - // - // In DWG format the sheet ("overall") viewport always has its center at - // the paper-space origin (0, 0). Content viewports are placed at their - // actual position on the paper and therefore have a non-zero center. - // Using center position is more reliable than a scale heuristic because - // the sheet viewport's scale is not always exactly 1:1 (observed: 0.8965 - // in real-world files, which the old 0.02 tolerance missed entirely). - vp.center.x.abs() >= 0.5 || vp.center.y.abs() >= 0.5 + if vp.id > 1 { + return false; + } + document.objects.values().any(|object| { + matches!( + object, + ObjectType::Layout(layout) + if layout.block_record == vp.common.owner_handle + && Self::layout_sheet_viewport_handle(document, layout) + == vp.common.handle + ) + }) + } + + pub(crate) fn sheet_viewport_handles(&self) -> std::collections::HashSet { + self.document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) => { + let handle = Self::layout_sheet_viewport_handle(&self.document, layout); + handle.is_valid().then_some(handle) + } + _ => None, + }) + .collect() } fn current_layout_sheet_viewport_handle(&self) -> Handle { @@ -3035,7 +3099,7 @@ impl Scene { return None; }; if layout.name == self.current_layout { - Some(layout.viewport) + Some(Self::layout_sheet_viewport_handle(&self.document, layout)) } else { None } @@ -3043,82 +3107,49 @@ impl Scene { .unwrap_or(Handle::NULL) } - /// Guarantee that a paper layout has its full-screen overall (`id == 1`) - /// sheet viewport. `add_layout` creates it; this is a safety net for layouts - /// that arrive without it. The sheet - /// viewport is the authoritative paper-space view and the canvas every - /// floating viewport overlays. + /// Ensure a paper layout has its overall (`id == 1`) viewport. pub fn ensure_sheet_viewport(&mut self, layout_name: &str) { if layout_name == "Model" { return; } - // Locate the layout: its object handle, block-record handle, current - // sheet-viewport link, and paper limits. + // Locate the layout and its paper limits. let info = self.document.objects.iter().find_map(|(h, obj)| { if let ObjectType::Layout(l) = obj { if l.name == layout_name { - return Some((*h, l.block_record, l.viewport, l.min_limits, l.max_limits)); + return Some(( + *h, + l.block_record, + Self::layout_sheet_viewport_handle(&self.document, l), + l.min_limits, + l.max_limits, + )); } } None }); - let Some((layout_handle, block_record, cur_vp, min_lim, max_lim)) = info else { + let Some((layout_handle, block_record, sheet, min_lim, max_lim)) = info else { return; }; if block_record.is_null() { return; } - // Normal files carry a valid direct Layout→Viewport link. This O(1) - // path is hit on every ordinary layout-tab switch. - if cur_vp.is_valid() + if sheet.is_valid() && matches!( - self.document.get_entity(cur_vp), + self.document.get_entity(sheet), Some(EntityType::Viewport(vp)) if vp.common.owner_handle == block_record ) { return; } - // Already present? Accept either the linked viewport handle or any - // `id == 1` viewport owned by the layout block. - let has_sheet = self.document.entities().any(|e| { - matches!(e, EntityType::Viewport(vp) - if vp.common.owner_handle == block_record - && (vp.id == 1 || vp.common.handle == cur_vp)) - }); - if has_sheet { - // Keep the layout's link in sync if it was missing. - if !cur_vp.is_valid() { - let h = self.document.entities().find_map(|e| match e { - EntityType::Viewport(vp) - if vp.common.owner_handle == block_record && vp.id == 1 => - { - Some(vp.common.handle) - } - _ => None, - }); - if let Some(h) = h { - if let Some(ObjectType::Layout(l)) = - self.document.objects.get_mut(&layout_handle) - { - l.viewport = h; - } - } - } - return; - } - // Create the full-screen overall viewport covering the paper limits. let pw = (max_lim.0 - min_lim.0).abs().max(1.0); let ph = (max_lim.1 - min_lim.1).abs().max(1.0); let mut vp = acadrust::entities::Viewport::new(); vp.id = 1; vp.status = acadrust::entities::ViewportStatusFlags::default_on(); - // Paper-space center is a 2D (x, y) point with z = 0. Putting the - // paper-height midpoint in z - // (with y = 0) left the sheet view centered at y = 0, shifting the whole - // layout half a page down. See issue #156. + // Paper-space center is an (x, y) point with z = 0. vp.center = acadrust::types::Vector3::new( (min_lim.0 + max_lim.0) / 2.0, (min_lim.1 + max_lim.1) / 2.0, @@ -3126,12 +3157,7 @@ impl Scene { ); vp.width = pw; vp.height = ph; - // Frame the new layout on the whole sheet: look straight down at the - // paper centre with the visible height a touch taller than the page. - // Without this the viewport keeps `Viewport::new`'s default view - // (target 0,0 / height 210), so the first time a fresh drawing's - // layout is opened the camera sits on the paper's bottom-left corner - // instead of centring the sheet. + // Frame the full sheet with a small margin. vp.view_target = acadrust::types::Vector3::new( (min_lim.0 + max_lim.0) / 2.0, (min_lim.1 + max_lim.1) / 2.0, @@ -3144,7 +3170,8 @@ impl Scene { .add_entity_to_layout(EntityType::Viewport(vp), layout_name) { if let Some(ObjectType::Layout(l)) = self.document.objects.get_mut(&layout_handle) { - l.viewport = handle; + l.viewports.retain(|candidate| *candidate != handle); + l.viewports.insert(0, handle); } } } @@ -3161,7 +3188,7 @@ impl Scene { if sheet_handle.is_valid() { vp.common.handle != sheet_handle } else { - Self::is_content_viewport(vp) + !Self::is_sheet_viewport(&self.document, vp) } } diff --git a/src/scene/paper.rs b/src/scene/paper.rs index db2a4e76..d7a1d928 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -29,7 +29,7 @@ impl Scene { && if sheet.is_valid() { handle != sheet } else { - Self::is_content_viewport(vp) + !Self::is_sheet_viewport(&self.document, vp) } }; let content = if let Some(block) = self From 8aca9a0aef97dd015f3e936bc428ffc63ad6349c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:32:37 +0300 Subject: [PATCH 87/98] Complete arc length dimension workflow --- src/app/commands/dim.rs | 7 + src/app/properties.rs | 18 +- src/entities/dim_override.rs | 8 + src/entities/dimension.rs | 206 +++++++- src/modules/annotate/arc_length_dim.rs | 638 +++++++++++++++++++++++++ src/modules/annotate/mod.rs | 6 + src/modules/draw/modify/explode.rs | 16 +- src/scene/dimension_assoc.rs | 142 +++++- 8 files changed, 1026 insertions(+), 15 deletions(-) create mode 100644 src/modules/annotate/arc_length_dim.rs diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 0138c9fc..dce8a510 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -55,6 +55,13 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "DIMARC" => { + use crate::modules::annotate::arc_length_dim::ArcLengthDimensionCommand; + let new_cmd = ArcLengthDimensionCommand::new(); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "DIMORDINATE" => { use crate::modules::annotate::ordinate_dim::OrdinateDimCommand; let new_cmd = OrdinateDimCommand::new(); diff --git a/src/app/properties.rs b/src/app/properties.rs index bd7daa3c..22534279 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1725,7 +1725,16 @@ impl OpenCADStudio { _ => None, }; if let Some((anno_field, insert_after)) = anno { - let is_anno = crate::scene::annotative::is_annotative(doc, entity); + let is_anno = crate::scene::annotative::is_annotative(doc, entity) + || match entity { + acadrust::EntityType::Dimension(dimension) => { + crate::scene::annotative::dim_style_is_annotative( + doc, + &dimension.base().style_name, + ) + } + _ => false, + }; // Dimensions/tables carry no Annotative row yet — add one // right after their style row. if let Some(anchor) = insert_after { @@ -1754,6 +1763,13 @@ impl OpenCADStudio { value: t.is_annotative, }, ), + acadrust::EntityType::Dimension( + acadrust::entities::Dimension::Arc(_), + ) => set_row( + &mut sections, + "annotative", + if is_anno { "Yes" } else { "No" }.to_string(), + ), acadrust::EntityType::Text(_) | acadrust::EntityType::Insert(_) | acadrust::EntityType::Hatch(_) diff --git a/src/entities/dim_override.rs b/src/entities/dim_override.rs index 9aad267e..893c6c42 100644 --- a/src/entities/dim_override.rs +++ b/src/entities/dim_override.rs @@ -286,6 +286,7 @@ pub fn property_int_code(field: &str) -> Option { "dim_tolerance_precision" => DIMTDEC, "dim_tolerance_pos_vert" => DIMTOLJ, "dim_tolerance_alignment" => DIMTALN, + "dim_arc_symbol" => DIMARCSYM, _ => return None, }) } @@ -337,6 +338,7 @@ fn inherited_int(doc: &CadDocument, handle: Handle, code: i16) -> i16 { DIMADEC => style.dimadec, DIMTFILL => style.dimtfill, DIMTALN => 0, + DIMARCSYM => style.dimarcsym, _ => 0, } } @@ -703,6 +705,12 @@ pub fn set_property( "move text, no leader" => Some(2), _ => trimmed.parse().ok(), }, + "dim_arc_symbol" => match trimmed.to_ascii_lowercase().as_str() { + "preceding dimension text" => Some(0), + "above dimension text" => Some(1), + "none" => Some(2), + _ => trimmed.parse().ok(), + }, "dim_alt_format" => match trimmed.to_ascii_lowercase().as_str() { "scientific" => Some(1), "decimal" => Some(2), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index ac62e24b..18bf5b98 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -175,6 +175,16 @@ fn properties(dim: &Dimension) -> Vec { ], }]; } + if let Dimension::Arc(d) = dim { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: PropValue::PlainText(d.base.style_name.clone()), + }], + }]; + } let mut props = base_props(dim.base()); match dim { Dimension::Aligned(d) => { @@ -2598,6 +2608,33 @@ pub fn style_sections( } } + if matches!(dimension, Dimension::Arc(_)) { + if let Some(lines) = sections + .iter_mut() + .find(|section| section.title == t!("Lines & Arrows").as_ref()) + { + let symbol = match int(ov::DIMARCSYM, s.dimarcsym) { + 1 => "Above dimension text", + 2 => "None", + _ => "Preceding dimension text", + }; + lines.props.insert( + 3, + choice( + t!("Arc length symbol").as_ref(), + "dim_arc_symbol", + symbol, + &[ + "Preceding dimension text", + "Above dimension text", + "None", + ], + true, + ), + ); + } + } + if matches!(dimension, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_)) { if let Some(text_section) = sections .iter_mut() @@ -3405,10 +3442,8 @@ fn tessellate_dimension_inner( // DIMUPT governs interactive creation-time text placement; saved // geometry already carries the resulting position. let _ = s.dimupt; - // DIMARCSYM only applies to arc-length dims; DIMJOGANG only to - // jogged-radius dims. We don't ship those Dimension variants yet, - // so the values are read for round-trip but not drawn. - let _ = (s.dimarcsym, s.dimjogang); + // DIMJOGANG is consumed by the jogged-radius path. + let _ = s.dimjogang; // DIMUNIT is the obsolete pre-R2000 linear unit format; DIMLUNIT // supersedes it. Read but not honoured. let _ = s.dimunit; @@ -3634,6 +3669,46 @@ fn tessellate_dimension_inner( fill_tris_low: Vec::new(), }); + if let Some(symbol) = style.and_then(|style| { + arc_length_symbol_points(dim, Some(style), dim_txt, dim_scale, style.dimarcsym) + }) { + let mut points = Vec::new(); + add_polyline(&mut points, &symbol); + wires.push(WireModel { + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.clone(), + points, + points_low: Vec::new(), + color: if selected { WireModel::SELECTED } else { text_color }, + selected, + aci: 0, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: vec![], + tangent_geoms: vec![], + key_vertices: vec![], + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } + // DIMTFILL: 0=none, 1=drawing background (mask), 2=DIMTFILLCLR. if let Some(s) = style { if s.dimtfill == 1 || s.dimtfill == 2 { @@ -3935,6 +4010,57 @@ fn text_fill_rect( let p4 = corner(-hx, hy); Some(vec![p1, p2, p3, p1, p3, p4]) } + +fn arc_length_symbol_points( + dim: &Dimension, + style: Option<&DimStyle>, + text_height: f64, + dim_scale: f64, + symbol_position: i16, +) -> Option> { + if !matches!(dim, Dimension::Arc(_)) || symbol_position == 2 { + return None; + } + let value = dimension_text_value(dim, style)?; + if value.is_empty() || text_height <= 1.0e-12 { + return None; + } + + let position = dimension_text_pos_f64(dim, style, text_height, dim_scale); + let rotation = dimension_text_rotation(dim, style); + let (sin_rotation, cos_rotation) = rotation.sin_cos(); + let text_width = value.chars().count() as f64 * text_height * 0.6; + let symbol_width = text_height * 0.62; + let symbol_height = text_height * 0.20; + let (center_x, center_y) = if symbol_position == 1 { + (0.0, text_height * 0.72) + } else { + (-(text_width * 0.5 + symbol_width * 0.70), text_height * 0.02) + }; + let transform = |x: f64, y: f64| { + let local_x = center_x + x; + let local_y = center_y + y; + Vec3::new( + (position.x + local_x * cos_rotation - local_y * sin_rotation) as f32, + (position.y + local_x * sin_rotation + local_y * cos_rotation) as f32, + position.z as f32, + ) + }; + + let steps = 8usize; + Some( + (0..=steps) + .map(|index| { + let t = index as f64 / steps as f64; + let x = (t - 0.5) * symbol_width; + let normalized = x / (symbol_width * 0.5); + let y = symbol_height * (1.0 - normalized * normalized); + transform(x, y) + }) + .collect(), + ) +} + struct SuppressFlags { ext1: bool, ext2: bool, @@ -4170,6 +4296,7 @@ fn dimension_geometry( } } Dimension::Arc(d) => { + let explicit_sweep = arc_dimension_angles(d); append_angular_dimension( &mut g, lv(d.center_point), @@ -4178,10 +4305,7 @@ fn dimension_geometry( lv(d.definition_point), arrow1, arrow2, - d.is_partial.then_some(( - d.arc_start_parameter as f32, - d.arc_end_parameter as f32, - )), + explicit_sweep, params, suppress, ); @@ -4585,6 +4709,25 @@ fn two_line_angle_frame( Some((vertex, start, end)) } +pub(crate) fn arc_dimension_angles(dimension: &DimensionArc) -> Option<(f32, f32)> { + let raw = dimension.arc_end_parameter - dimension.arc_start_parameter; + let mut sweep = raw.rem_euclid(std::f64::consts::TAU); + if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 { + sweep = std::f64::consts::TAU; + } + if sweep > 1.0e-12 { + let start = dimension.arc_start_parameter as f32; + return Some((start, start + sweep as f32)); + } + + let start = (dimension.first_extension_point.y - dimension.center_point.y) + .atan2(dimension.first_extension_point.x - dimension.center_point.x); + let end = (dimension.second_extension_point.y - dimension.center_point.y) + .atan2(dimension.second_extension_point.x - dimension.center_point.x); + let sweep = (end - start).rem_euclid(std::f64::consts::TAU); + (sweep > 1.0e-12).then_some((start as f32, (start + sweep) as f32)) +} + fn angular_dimension_frame(dim: &Dimension) -> Option<(Vec3, f32, f32, f32)> { let (vertex, start, end, arc_point) = match dim { Dimension::Angular2Ln(value) => { @@ -4611,6 +4754,12 @@ fn angular_dimension_frame(dim: &Dimension) -> Option<(Vec3, f32, f32, f32)> { two_line_angle_frame(vertex, first, vertex, second, arc_point)?; (vertex, start, end, arc_point) } + Dimension::Arc(value) => { + let vertex = vec3_local(value.center_point); + let arc_point = vec3_local(value.definition_point); + let (start, end) = arc_dimension_angles(value)?; + (vertex, start, end, arc_point) + } _ => return None, }; let radius = vertex.distance(arc_point); @@ -5201,7 +5350,7 @@ fn dimension_text_natural_rotation(dim: &Dimension) -> f64 { let dy = d.second_point.y - d.first_point.y; dy.atan2(dx) } - Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) => angular_dimension_frame(dim) + Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_) | Dimension::Arc(_) => angular_dimension_frame(dim) .map(|(_, start, end, _)| { ((start + end) * 0.5 + std::f32::consts::FRAC_PI_2) as f64 }) @@ -6135,6 +6284,45 @@ pub(crate) fn baked_dimension_text_entity( Some(ent) } +pub(crate) fn baked_arc_length_symbol_points( + dim: &Dimension, + document: &CadDocument, + anno_scale: f64, +) -> Vec { + if !matches!(dim, Dimension::Arc(_)) { + return Vec::new(); + } + let style_name = dim.base().style_name.as_str(); + let style = document.dim_styles.iter().find(|style| { + style.name.eq_ignore_ascii_case(style_name) + || (style_name.trim().is_empty() && style.name.eq_ignore_ascii_case("Standard")) + }); + let dim_scale = style + .map(|style| { + if style.dimscale > 1.0e-6 { + style.dimscale + } else { + anno_scale + } + }) + .unwrap_or(1.0); + let text_height = style + .map(|style| style.dimtxt * dim_scale) + .unwrap_or(2.5 * dim_scale); + let symbol_position = crate::entities::dim_override::int( + &dim.base().common.extended_data, + crate::entities::dim_override::DIMARCSYM, + ) + .or_else(|| style.map(|style| style.dimarcsym)) + .unwrap_or(0); + + arc_length_symbol_points(dim, style, text_height, dim_scale, symbol_position) + .unwrap_or_default() + .into_iter() + .map(|point| Vector3::new(point.x as f64, point.y as f64, point.z as f64)) + .collect() +} + pub(crate) fn dimension_text_grip_position( dim: &Dimension, document: &CadDocument, diff --git a/src/modules/annotate/arc_length_dim.rs b/src/modules/annotate/arc_length_dim.rs new file mode 100644 index 00000000..28ab7b8a --- /dev/null +++ b/src/modules/annotate/arc_length_dim.rs @@ -0,0 +1,638 @@ +use acadrust::entities::{Dimension, DimensionArc}; +use acadrust::types::{Handle, Vector3}; +use acadrust::EntityType; +use cadkernel::geom2d::tessellate::{arc, DEFAULT_SEGMENTS_PER_RADIAN}; +use glam::DVec3; + +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, + DimensionAssociationSource, WorkingPlane, +}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::dimension_assoc::{ + polyline_arc_point_marker, RadialSourceGeometry, + POLYLINE_ARC_CENTER_MARKER, +}; +use crate::scene::model::wire_model::WireModel; + +pub const ICON: IconKind = + IconKind::Svg(include_bytes!("../../../assets/icons/dim_angular.svg")); + +pub fn tool() -> ToolDef { + ToolDef { + id: "DIMARC", + label: "Arc Length", + icon: ICON, + event: ModuleEvent::Command("DIMARC".to_string()), + } +} + +#[derive(Clone, Copy)] +enum SourceBinding { + Arc, + PolylineSegment(i32), +} + +#[derive(Clone, Copy)] +struct ArcSelection { + source: RadialSourceGeometry, + binding: SourceBinding, + start_angle: f64, + end_angle: f64, + is_partial: bool, +} + +impl ArcSelection { + fn new(source: RadialSourceGeometry, binding: SourceBinding) -> Option { + let sweep = positive_sweep(source.start_angle, source.end_angle); + (source.limited && source.radius > 1.0e-12 && sweep > 1.0e-12).then_some(Self { + source, + binding, + start_angle: source.start_angle, + end_angle: source.start_angle + sweep, + is_partial: false, + }) + } + + fn sweep(self) -> f64 { + positive_sweep(self.start_angle, self.end_angle) + } + + fn point_at(self, angle: f64) -> DVec3 { + dvec(self.source.point_at_angle(angle)) + } + + fn center(self) -> DVec3 { + dvec(self.source.center_world()) + } + + fn plane(self) -> WorkingPlane { + WorkingPlane::new( + DVec3::from_array(self.source.plane.origin), + DVec3::from_array(self.source.plane.x_axis), + DVec3::from_array(self.source.plane.y_axis), + ) + } + + fn clamped_angle(self, point: DVec3) -> f64 { + let raw = self.source.angle_at(point.to_array()); + let relative = (raw - self.start_angle).rem_euclid(std::f64::consts::TAU); + let sweep = self.sweep(); + if relative <= sweep + 1.0e-10 { + return self.start_angle + relative.min(sweep); + } + let start_distance = relative.min(std::f64::consts::TAU - relative); + let end_relative = (raw - self.end_angle).rem_euclid(std::f64::consts::TAU); + let end_distance = end_relative.min(std::f64::consts::TAU - end_relative); + if start_distance <= end_distance { + self.start_angle + } else { + self.end_angle + } + } + + fn with_partial(self, first: f64, second: f64) -> Option { + let first = (first - self.start_angle) + .rem_euclid(std::f64::consts::TAU) + .clamp(0.0, self.sweep()); + let second = (second - self.start_angle) + .rem_euclid(std::f64::consts::TAU) + .clamp(0.0, self.sweep()); + let (start, end) = if first <= second { + (first, second) + } else { + (second, first) + }; + (end - start > 1.0e-12).then_some(Self { + start_angle: self.start_angle + start, + end_angle: self.start_angle + end, + is_partial: true, + ..self + }) + } + + fn association_sources(self, handle: Handle) -> Vec> { + match self.binding { + SourceBinding::Arc => vec![ + Some(DimensionAssociationSource::explicit(handle, -3, 0.0)), + Some(DimensionAssociationSource::explicit( + handle, + -2, + self.start_angle, + )), + Some(DimensionAssociationSource::explicit( + handle, + -2, + self.end_angle, + )), + ], + SourceBinding::PolylineSegment(segment) => { + let point_marker = polyline_arc_point_marker(segment); + vec![ + Some(DimensionAssociationSource::explicit( + handle, + POLYLINE_ARC_CENTER_MARKER, + segment as f64, + )), + Some(DimensionAssociationSource::explicit( + handle, + point_marker, + self.start_angle, + )), + Some(DimensionAssociationSource::explicit( + handle, + point_marker, + self.end_angle, + )), + ] + } + } + } +} + +#[derive(Clone, Copy)] +enum Step { + SelectObject, + DimLine(ArcSelection), + PartialFirst(ArcSelection), + PartialSecond { + selection: ArcSelection, + first_angle: f64, + }, +} + +pub struct ArcLengthDimensionCommand { + step: Step, + picked_entity: Option, + source_handle: Option, + text_override: Option, + awaiting_text: bool, + text_angle: Option, + awaiting_angle: bool, + leader_enabled: bool, +} + +impl ArcLengthDimensionCommand { + pub fn new() -> Self { + Self { + step: Step::SelectObject, + picked_entity: None, + source_handle: None, + text_override: None, + awaiting_text: false, + text_angle: None, + awaiting_angle: false, + leader_enabled: false, + } + } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::SelectObject => DVec3::ZERO, + Step::DimLine(selection) + | Step::PartialFirst(selection) + | Step::PartialSecond { selection, .. } => { + selection.point_at(selection.start_angle + selection.sweep() * 0.5) + } + } + } + + fn commit_dimension(&self, selection: ArcSelection, point: DVec3) -> CmdResult { + let plane = selection.plane(); + let center = plane.to_local(selection.center()); + let first = plane.to_local(selection.point_at(selection.start_angle)); + let second = plane.to_local(selection.point_at(selection.end_angle)); + let picked = plane.to_local(point); + let picked_radius = (picked - center).truncate().length(); + if !picked_radius.is_finite() || picked_radius <= 1.0e-12 { + return CmdResult::NeedPoint; + } + + let mut dimension = DimensionArc::default(); + dimension.center_point = v3(center); + dimension.first_extension_point = v3(first); + dimension.second_extension_point = v3(second); + dimension.definition_point = v3(picked); + dimension.is_partial = selection.is_partial; + dimension.arc_start_parameter = selection.start_angle; + dimension.arc_end_parameter = selection.end_angle; + dimension.base.definition_point = dimension.definition_point; + dimension.base.text_middle_point = dimension.definition_point; + dimension.base.insertion_point = dimension.definition_point; + dimension.has_leader = self.leader_enabled && selection.sweep() > std::f64::consts::FRAC_PI_2; + if dimension.has_leader { + let middle = selection.start_angle + selection.sweep() * 0.5; + let anchor = DVec3::new( + center.x + picked_radius * middle.cos(), + center.y + picked_radius * middle.sin(), + picked.z, + ); + dimension.first_leader_point = v3(anchor); + dimension.second_leader_point = v3(picked); + dimension.definition_point = v3(anchor); + dimension.base.definition_point = dimension.definition_point; + dimension.base.text_user_positioned = true; + } + dimension.base.actual_measurement = dimension.measurement(); + crate::entities::dimension::set_dimension_text_override( + &mut dimension.base, + self.text_override.clone(), + ); + if let Some(angle) = self.text_angle { + dimension.base.text_rotation = angle; + } + + let association = self.source_handle.map_or_else( + || DimensionAssociationInput::Explicit(Vec::new()), + |handle| { + DimensionAssociationInput::Explicit( + selection.association_sources(handle), + ) + }, + ); + CmdResult::CommitDimension { + entity: plane.place_entity(EntityType::Dimension(Dimension::Arc(dimension))), + association, + } + } +} + +impl CadCommand for ArcLengthDimensionCommand { + fn set_working_plane(&mut self, _plane: WorkingPlane) {} + + fn name(&self) -> &'static str { + "DIMARC" + } + + fn prompt(&self) -> String { + if self.awaiting_text { + return "DIMARC Enter dimension text (blank = measured value):".to_string(); + } + if self.awaiting_angle { + return "DIMARC Specify text angle (degrees):".to_string(); + } + match self.step { + Step::SelectObject => { + "DIMARC Select arc or polyline arc segment:".to_string() + } + Step::DimLine(selection) => { + let leader_option = if selection.sweep() > std::f64::consts::FRAC_PI_2 { + if self.leader_enabled { "/No Leader" } else { "/Leader" } + } else { + "" + }; + format!( + "DIMARC Specify arc length dimension location [Mtext/Text/Angle/Partial{leader_option}]:" + ) + } + Step::PartialFirst(_) => { + "DIMARC Specify first point of partial arc:".to_string() + } + Step::PartialSecond { .. } => { + "DIMARC Specify second point of partial arc:".to_string() + } + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::SelectObject => CmdResult::NeedPoint, + Step::DimLine(selection) => self.commit_dimension(selection, point), + Step::PartialFirst(selection) => { + self.step = Step::PartialSecond { + selection, + first_angle: selection.clamped_angle(point), + }; + CmdResult::NeedPoint + } + Step::PartialSecond { + selection, + first_angle, + } => { + let second_angle = selection.clamped_angle(point); + let Some(partial) = selection.with_partial(first_angle, second_angle) else { + return CmdResult::NeedPoint; + }; + self.leader_enabled = false; + self.step = Step::DimLine(partial); + CmdResult::NeedPoint + } + } + } + + fn on_enter(&mut self) -> CmdResult { + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + return CmdResult::NeedPoint; + } + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + !self.awaiting_text && !self.awaiting_angle + } + + fn wants_text_with_spaces(&self) -> bool { + self.awaiting_text + } + + fn options(&self) -> Vec { + let Step::DimLine(selection) = self.step else { + return Vec::new(); + }; + if self.awaiting_text || self.awaiting_angle { + return Vec::new(); + } + let mut options = vec![ + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + CmdOption::new("Partial", "PARTIAL"), + ]; + if selection.sweep() > std::f64::consts::FRAC_PI_2 { + options.push(if self.leader_enabled { + CmdOption::new("No Leader", "NOLEADER") + } else { + CmdOption::new("Leader", "LEADER") + }); + } + options + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_text { + let value = text.trim(); + self.text_override = if value.is_empty() || value == "<>" { + None + } else { + Some(value.to_string()) + }; + self.awaiting_text = false; + return Some(CmdResult::NeedPoint); + } + if self.awaiting_angle { + let value = text.trim(); + self.text_angle = if value.is_empty() { + None + } else { + crate::entities::common::parse_typed_angle(value) + }; + self.awaiting_angle = false; + return Some(CmdResult::NeedPoint); + } + let Step::DimLine(selection) = self.step else { + return None; + }; + match text.trim().to_ascii_uppercase().as_str() { + "M" | "MTEXT" => Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: 2.5, + }), + "T" | "TEXT" => { + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "A" | "ANGLE" => { + self.awaiting_angle = true; + Some(CmdResult::NeedPoint) + } + "P" | "PARTIAL" => { + self.step = Step::PartialFirst(selection); + Some(CmdResult::NeedPoint) + } + "L" | "LEADER" if selection.sweep() > std::f64::consts::FRAC_PI_2 => { + self.leader_enabled = true; + Some(CmdResult::NeedPoint) + } + "N" | "NOLEADER" => { + self.leader_enabled = false; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn on_editor_text(&mut self, value: String) { + let value = value.trim(); + self.text_override = if value.is_empty() || value == "<>" { + None + } else { + Some(value.to_string()) + }; + } + + fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { + CmdResult::NeedPoint + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::SelectObject) + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(source) = crate::scene::dimension_assoc::radial_source_at( + &entity, + Vector3::new(point.x, point.y, point.z), + ) else { + return CmdResult::NeedPoint; + }; + if !source.limited { + return CmdResult::NeedPoint; + } + let binding = match entity { + EntityType::Arc(_) => SourceBinding::Arc, + EntityType::LwPolyline(_) | EntityType::Polyline2D(_) => { + SourceBinding::PolylineSegment(source.marker) + } + _ => return CmdResult::NeedPoint, + }; + let Some(selection) = ArcSelection::new(source, binding) else { + return CmdResult::NeedPoint; + }; + self.source_handle = Some(handle); + self.step = Step::DimLine(selection); + CmdResult::NeedPoint + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match self.step { + Step::SelectObject | Step::PartialFirst(_) => None, + Step::DimLine(selection) => Some(dimension_preview( + selection, + point, + self.leader_enabled, + )), + Step::PartialSecond { + selection, + first_angle, + } => { + let second_angle = selection.clamped_angle(point); + let partial = selection.with_partial(first_angle, second_angle)?; + Some(partial_preview(partial)) + } + } + } +} + +fn positive_sweep(start: f64, end: f64) -> f64 { + let raw = end - start; + let mut sweep = raw.rem_euclid(std::f64::consts::TAU); + if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 { + sweep = std::f64::consts::TAU; + } + sweep +} + +fn dimension_preview( + selection: ArcSelection, + point: DVec3, + leader: bool, +) -> WireModel { + let plane = selection.plane(); + let center = plane.to_local(selection.center()); + let first = plane.to_local(selection.point_at(selection.start_angle)); + let second = plane.to_local(selection.point_at(selection.end_angle)); + let point = plane.to_local(point); + let radius = (point - center).truncate().length(); + if radius <= 1.0e-12 { + return preview_wire(Vec::new(), "dimarc_preview"); + } + let start_land = DVec3::new( + center.x + radius * selection.start_angle.cos(), + center.y + radius * selection.start_angle.sin(), + point.z, + ); + let end_land = DVec3::new( + center.x + radius * selection.end_angle.cos(), + center.y + radius * selection.end_angle.sin(), + point.z, + ); + let mut points = vec![first, start_land, nan(), second, end_land, nan()]; + points.extend( + arc( + [center.x, center.y], + radius, + selection.start_angle, + selection.end_angle, + point.z, + DEFAULT_SEGMENTS_PER_RADIAN, + ) + .into_iter() + .map(DVec3::from_array), + ); + if leader && selection.sweep() > std::f64::consts::FRAC_PI_2 { + let middle = selection.start_angle + selection.sweep() * 0.5; + points.extend([ + nan(), + DVec3::new( + center.x + radius * middle.cos(), + center.y + radius * middle.sin(), + point.z, + ), + point, + ]); + } + preview_wire( + points + .into_iter() + .map(|value| if value.is_nan() { value } else { plane.to_world(value) }) + .collect(), + "dimarc_preview", + ) +} + +fn partial_preview(selection: ArcSelection) -> WireModel { + let points = arc( + selection.source.center, + selection.source.radius, + selection.start_angle, + selection.end_angle, + 0.0, + DEFAULT_SEGMENTS_PER_RADIAN, + ) + .into_iter() + .map(|point| DVec3::from_array(selection.source.plane.point_at([point[0], point[1]]))) + .collect(); + preview_wire(points, "dimarc_partial_preview") +} + +fn v3(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn nan() -> DVec3 { + DVec3::splat(f64::NAN) +} + +fn preview_wire(points: Vec, name: &str) -> WireModel { + WireModel { + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.to_string(), + points: points + .into_iter() + .map(|point| [point.x as f32, point.y as f32, point.z as f32]) + .collect(), + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: vec![], + tangent_geoms: vec![], + aci: 0, + key_vertices: vec![], + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: vec![], + fill_tris_low: Vec::new(), + } +} + +inventory::submit!(crate::command::CommandRegistration { names: &["DIMARC"] }); diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index cb6d65f1..bb0f2c1f 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -1,6 +1,7 @@ // Annotate module — dimension, text, leader, table, and markup tools. pub mod aligned_dim; +pub mod arc_length_dim; pub mod angular_dim; pub mod data_extract; pub mod data_link; @@ -98,6 +99,11 @@ impl CadModule for AnnotateModule { angular_dim::tool().label, angular_dim::tool().icon, ), + ( + arc_length_dim::tool().id, + arc_length_dim::tool().label, + arc_length_dim::tool().icon, + ), ( radius_dim::tool().id, radius_dim::tool().label, diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 9dff1b97..2e5562d8 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -1190,6 +1190,8 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { } } Dimension::Arc(d) => { + let explicit_sweep = crate::entities::dimension::arc_dimension_angles(d) + .map(|(start, end)| (start as f64, end as f64)); result.extend(angular_block_segs( d.center_point, d.first_extension_point, @@ -1198,10 +1200,7 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { &met, &ext_c, &dim_c, - d.is_partial.then_some(( - d.arc_start_parameter, - d.arc_end_parameter, - )), + explicit_sweep, )); if d.has_leader { result.push(make_seg( @@ -1235,6 +1234,15 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { } } + let symbol_points = + crate::entities::dimension::baked_arc_length_symbol_points(dim, doc, 1.0); + if symbol_points.len() > 1 { + let text_c = dim_common(&base.common, met.dimclrt, -2); + for pair in symbol_points.windows(2) { + result.push(make_seg(&pair[0], &pair[1], &text_c)); + } + } + // Measurement text is the live render's own Text/MText entity (value, // position, height, rotation, alignment, text style, MText handling all // shared), so the baked block matches the on-screen dimension and the label diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 43901eea..a9fee8b1 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -16,6 +16,18 @@ use crate::command::DimensionAssociationSource; use super::{ChangeKind, Scene}; +pub(crate) const POLYLINE_ARC_CENTER_MARKER: i32 = -4; +const POLYLINE_ARC_POINT_MARKER_BASE: i32 = -5; + +pub(crate) fn polyline_arc_point_marker(segment: i32) -> i32 { + POLYLINE_ARC_POINT_MARKER_BASE - segment.max(0) +} + +fn polyline_arc_segment_from_point_marker(marker: i32) -> Option { + (marker <= POLYLINE_ARC_POINT_MARKER_BASE) + .then_some(POLYLINE_ARC_POINT_MARKER_BASE - marker) +} + #[derive(Clone, Copy, Debug)] pub(crate) struct RadialSourceGeometry { pub plane: Plane, @@ -329,7 +341,13 @@ fn source_marker(entity: &EntityType, point: Vector3) -> Option { fn resolve_reference(scene: &Scene, reference: &AssocDimensionReference) -> Option { let source = *reference.xrefs.first()?; let entity = scene.document.get_entity(source)?; - if reference.main_gs_marker == -4 { + if let Some(segment) = + polyline_arc_segment_from_point_marker(reference.main_gs_marker) + { + let radial = radial_source_for_marker(entity, segment)?; + return Some(radial.point_at_angle(reference.osnap_distance)); + } + if reference.main_gs_marker == POLYLINE_ARC_CENTER_MARKER { let segment = reference.osnap_distance.round().max(0.0) as usize; return polyline_arc_center(entity, segment); } @@ -422,10 +440,55 @@ fn dimension_reference_points(dimension: &Dimension) -> Vec { angular.definition_point, ], Dimension::Ordinate(ordinate) => vec![ordinate.feature_location], + Dimension::Arc(arc) => vec![ + arc.center_point, + arc.first_extension_point, + arc.second_extension_point, + ], _ => Vec::new(), } } +fn positive_sweep(start: f64, end: f64) -> f64 { + let raw = end - start; + let mut sweep = raw.rem_euclid(TAU); + if sweep <= 1.0e-12 && raw.abs() > 1.0e-12 { + sweep = TAU; + } + sweep +} + +fn signed_angle_delta(value: f64) -> f64 { + (value + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI +} + +fn angle_about_plane( + plane: Plane, + center: Vector3, + point: Vector3, +) -> f64 { + let delta = [ + point.x - center.x, + point.y - center.y, + point.z - center.z, + ]; + let dot = |axis: [f64; 3]| { + delta[0] * axis[0] + delta[1] * axis[1] + delta[2] * axis[2] + }; + dot(plane.y_axis).atan2(dot(plane.x_axis)) +} + +fn point_on_radial_circle( + radial: RadialSourceGeometry, + radius: f64, + angle: f64, +) -> Vector3 { + vector3(radial.plane.point_at([ + radial.center[0] + radius * angle.cos(), + radial.center[1] + radius * angle.sin(), + ])) +} + pub(crate) fn dimension_is_associative( document: &acadrust::CadDocument, dimension: Handle, @@ -796,6 +859,18 @@ impl Scene { let radial = radial_source_for_marker(entity, reference.main_gs_marker)?; Some((radial, reference.osnap_distance)) }); + let arc_source = association.references[0].first().and_then(|reference| { + let source = *reference.xrefs.first()?; + let entity = self.document.get_entity(source)?; + let segment = match reference.main_gs_marker { + -3 => 0, + POLYLINE_ARC_CENTER_MARKER => { + reference.osnap_distance.round().max(0.0) as i32 + } + _ => return None, + }; + radial_source_for_marker(entity, segment) + }); if radial_source.is_none() && resolved.iter().all(Option::is_none) { continue; } @@ -898,6 +973,71 @@ impl Scene { } ordinate.refresh_measurement(); } + Dimension::Arc(arc) => { + let Some(radial) = arc_source else { + continue; + }; + let center = resolved[0].unwrap_or_else(|| radial.center_world()); + let Some(first) = resolved[1] else { + continue; + }; + let Some(second) = resolved[2] else { + continue; + }; + + let old_center = arc.center_point; + let old_definition = arc.definition_point; + let old_source_radius = old_center.distance(&arc.first_extension_point); + let old_dim_radius = old_center.distance(&old_definition); + let radial_offset = old_dim_radius - old_source_radius; + let old_mid = arc.arc_start_parameter + + positive_sweep( + arc.arc_start_parameter, + arc.arc_end_parameter, + ) * 0.5; + let old_definition_angle = + angle_about_plane(radial.plane, old_center, old_definition); + let definition_angle_offset = + signed_angle_delta(old_definition_angle - old_mid); + + let start = radial.angle_at(dpoint(first)); + let end_at = radial.angle_at(dpoint(second)); + let sweep = positive_sweep(start, end_at); + let end = start + sweep; + let new_radius = center.distance(&first); + if !new_radius.is_finite() || new_radius <= 1.0e-12 { + continue; + } + let dim_radius = (new_radius + radial_offset).max(1.0e-9); + let middle = start + sweep * 0.5; + let definition = point_on_radial_circle( + radial, + dim_radius, + middle + definition_angle_offset, + ); + let delta = definition - old_definition; + + arc.center_point = center; + arc.first_extension_point = first; + arc.second_extension_point = second; + arc.arc_start_parameter = start; + arc.arc_end_parameter = end; + arc.definition_point = definition; + arc.base.definition_point = definition; + if arc.base.text_user_positioned { + arc.base.text_middle_point = arc.base.text_middle_point + delta; + arc.base.insertion_point = arc.base.insertion_point + delta; + } else { + arc.base.text_middle_point = definition; + arc.base.insertion_point = definition; + } + if arc.has_leader { + arc.first_leader_point = + point_on_radial_circle(radial, dim_radius, middle); + arc.second_leader_point = arc.second_leader_point + delta; + } + arc.base.actual_measurement = arc.measurement(); + } _ => continue, } dimension.base_mut().actual_measurement = dimension.measurement(); From 83d41b0f7d3f1d219ea6c211324007dfcd5a87bd Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 15:56:03 +0300 Subject: [PATCH 88/98] Remove handle from general properties --- src/scene/cache/properties.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index fe199e88..974fc5a2 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -36,11 +36,6 @@ pub fn general_section(entity: &EntityType) -> PropSection { let mut section = PropSection { title: t!("General").into_owned(), props: vec![ - Property { - label: t!("Handle").into_owned(), - field: "handle", - value: PropValue::ReadOnly(common.handle.value().to_string()), - }, Property { label: t!("Color").into_owned(), field: "color", @@ -94,12 +89,7 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) { - section.props.retain(|prop| prop.field != "handle"); - } - if matches!(entity, EntityType::Point(_)) { - section.props.retain(|prop| prop.field != "handle"); let hyperlink = section.props.iter().position(|prop| prop.field == "hyperlink"); let transparency = section.props.iter().position(|prop| prop.field == "transparency"); if let (Some(hyperlink), Some(transparency)) = (hyperlink, transparency) { From 5a9894fd3eaa5a142c41450a293593aa635e0c7f Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Wed, 26 Aug 2026 17:17:55 +0300 Subject: [PATCH 89/98] Complete jogged radius dimension workflow --- locales/en-US/opencadstudio.ftl | 7 + locales/tr-TR/opencadstudio.ftl | 7 + src/app/commands/dim.rs | 54 +-- src/app/properties.rs | 39 +- src/entities/dimension.rs | 94 +++-- src/locale_catalog.rs | 7 + src/modules/annotate/jogged_radius_dim.rs | 476 ++++++++++++++++++++++ src/modules/annotate/mod.rs | 6 + src/modules/draw/modify/explode.rs | 17 +- src/scene/creation_style.rs | 4 + src/scene/dimension_assoc.rs | 38 ++ 11 files changed, 664 insertions(+), 85 deletions(-) create mode 100644 src/modules/annotate/jogged_radius_dim.rs diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 27566f98..03332480 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1864,6 +1864,12 @@ annotate = .dimjogged-created-a-jogged-radius-dimension = DIMJOGGED: created a jogged radius dimension. .dimjogged-invalid-radius = DIMJOGGED: invalid radius. .dimjogged-select-an-arc-or-circle-first = DIMJOGGED: select an arc or circle first. + .dimjogged-enter-dimension-text-blank-measured = DIMJOGGED Enter dimension text (blank = measured value): + .dimjogged-select-arc-circle-or-polyline-arc = DIMJOGGED Select arc, circle, or polyline arc: + .dimjogged-specify-center-location-override = DIMJOGGED Specify center location override: + .dimjogged-specify-dimension-line-location-text = DIMJOGGED Specify dimension line location [Mtext/Text/Angle]: + .dimjogged-specify-jog-location = DIMJOGGED Specify jog location: + .dimjogged-specify-text-angle-degrees = DIMJOGGED Specify text angle (degrees): .dimjogline-select-linear-or-aligned-dimension = DIMJOGLINE Select linear or aligned dimension: .dimjogline-specify-jog-location = DIMJOGLINE Specify jog location: .dimlinear-enter-dimension-text-blank-measured = DIMLINEAR Enter dimension text (blank = measured value): @@ -2667,6 +2673,7 @@ properties = .island-detection-style = Island detection style .italic = Italic .jog-angle = Jog Angle + .jogged-radius = Jogged Radius .jog-x = Jog X .jog-y = Jog Y .jog-z = Jog Z diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 62a06be4..32ac287e 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1844,6 +1844,12 @@ annotate = .dimjogged-created-a-jogged-radius-dimension = DIMJOGGED: kırıklı yarıçap ölçüsü oluşturuldu. .dimjogged-invalid-radius = DIMJOGGED: yarıçap geçersiz. .dimjogged-select-an-arc-or-circle-first = DIMJOGGED: önce bir yay veya daire seçin. + .dimjogged-enter-dimension-text-blank-measured = DIMJOGGED Ölçü metnini girin (boş = ölçülen değer): + .dimjogged-select-arc-circle-or-polyline-arc = DIMJOGGED Yay, daire veya çoklu çizgi yayı seçin: + .dimjogged-specify-center-location-override = DIMJOGGED Geçersiz kılınmış merkez konumunu belirtin: + .dimjogged-specify-dimension-line-location-text = DIMJOGGED Ölçü çizgisinin konumunu belirtin [Çok satırlı metin/Metin/Açı]: + .dimjogged-specify-jog-location = DIMJOGGED Kırılma konumunu belirtin: + .dimjogged-specify-text-angle-degrees = DIMJOGGED Metin açısını belirtin (derece): .dimjogline-select-linear-or-aligned-dimension = DIMJOGLINE Doğrusal veya hizalı ölçüyü seçin: .dimjogline-specify-jog-location = DIMJOGLINE Kırılma konumunu belirtin: .dimlinear-enter-dimension-text-blank-measured = DIMLINEAR Ölçü metnini girin (boş = ölçülen değer): @@ -2606,6 +2612,7 @@ properties = .island-detection-style = Ada algılama stili .italic = İtalik .jog-angle = Kırılma Açısı + .jogged-radius = Kırıklı Yarıçap .jog-x = Kırılma X .jog-y = Kırılma Y .jog-z = Kırılma Z diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index 0138c9fc..255bbbff 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -48,6 +48,17 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "DIMJOGGED" | "DIMJOG" => { + use crate::modules::annotate::jogged_radius_dim::JoggedRadiusDimensionCommand; + let defaults = crate::scene::creation_style::current_dimension_defaults( + &self.tabs[i].scene.document, + ); + let multiplier = self.tabs[i].scene.creation_annotation_multiplier(); + let new_cmd = JoggedRadiusDimensionCommand::new(defaults, multiplier); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "DIMANGULAR" => { use crate::modules::annotate::angular_dim::AngularDimensionCommand; let new_cmd = AngularDimensionCommand::new(); @@ -1289,49 +1300,6 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - // DIMJOGGED — create a jogged radius dimension on the selected - // arc/circle: a standard radius dim carrying an OCS_JOGGED XData marker - // that the geometry generator renders with a foreshortened zig-zag. - "DIMJOGGED" | "DIMJOG" => { - use acadrust::entities::{Dimension, DimensionRadius}; - use acadrust::types::Vector3; - use acadrust::xdata::{ExtendedDataRecord, XDataValue}; - let found = - self.tabs[i] - .scene - .selected_entities() - .iter() - .find_map(|(_, e)| match e { - acadrust::EntityType::Arc(a) => Some((a.center, a.radius)), - acadrust::EntityType::Circle(c) => Some((c.center, c.radius)), - _ => None, - }); - let Some((center, radius)) = found else { - self.command_line - .push_error(crate::t!("DIMJOGGED: select an arc or circle first.").as_ref()); - return None; - }; - if radius <= 0.0 { - self.command_line.push_error(crate::t!("DIMJOGGED: invalid radius.").as_ref()); - return None; - } - let k = std::f64::consts::FRAC_1_SQRT_2; - let arc_point = - Vector3::new(center.x + radius * k, center.y + radius * k, center.z); - let mut dim = DimensionRadius::new(center, arc_point); - dim.base.common.layer = self.tabs[i].active_layer.clone(); - let mut rec = ExtendedDataRecord::new("OCS_JOGGED"); - rec.add_value(XDataValue::String("1".to_string())); - dim.base.common.extended_data.add_record(rec); - self.push_undo_snapshot(i, "DIMJOGGED"); - self.tabs[i] - .scene - .add_entity(acadrust::EntityType::Dimension(Dimension::Radius(dim))); - self.tabs[i].dirty = true; - self.command_line - .push_output(crate::t!("DIMJOGGED: created a jogged radius dimension.").as_ref()); - } - // ARCTEXT — lay the text out as one Text entity per character // along the selected arc, each rotated to follow the tangent. "ARCTEXT" => { diff --git a/src/app/properties.rs b/src/app/properties.rs index bd7daa3c..b09dd37b 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1030,22 +1030,19 @@ impl OpenCADStudio { if !dim_style_names.is_empty() { // Current style is already shown as text in the geom section; // replace/upgrade it to a Choice if we have a list. - if let Some(geom) = sections.last_mut() { - // Replace the style name with a choice. - if let Some(prop) = - geom.props.iter_mut().find(|p| p.field == "style_name") - { - let current = match &prop.value { - crate::scene::model::object::PropValue::PlainText(s) => { - s.clone() - } - _ => String::new(), - }; - prop.value = crate::scene::model::object::PropValue::Choice { - selected: current, - options: dim_style_names, - }; - } + if let Some(prop) = sections + .iter_mut() + .flat_map(|section| section.props.iter_mut()) + .find(|property| property.field == "style_name") + { + let current = match &prop.value { + crate::scene::model::object::PropValue::PlainText(s) => s.clone(), + _ => String::new(), + }; + prop.value = crate::scene::model::object::PropValue::Choice { + selected: current, + options: dim_style_names, + }; } } @@ -1065,6 +1062,16 @@ impl OpenCADStudio { &self.tabs[i].scene.document, )); + if matches!(d, acadrust::entities::Dimension::LargeRadial(_)) { + if let Some(index) = sections + .iter() + .position(|section| section.title == t!("Misc").as_ref()) + { + let misc = sections.remove(index); + sections.push(misc); + } + } + // Prefer entity-level dimension-variable overrides; // fall back to the assigned style values. use crate::entities::dim_override as dov; diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index ac62e24b..8f296512 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -108,6 +108,47 @@ fn properties(dim: &Dimension) -> Vec { ], }]; } + if let Dimension::LargeRadial(d) = dim { + return vec![PropSection { + title: t!("Misc").into_owned(), + props: vec![ + Property { + label: t!("Dimension style").into_owned(), + field: "style_name", + value: PropValue::PlainText(d.base.style_name.clone()), + }, + edit(t!("Center X").as_ref(), "definition_x", d.definition_point.x), + edit(t!("Center Y").as_ref(), "definition_y", d.definition_point.y), + edit(t!("Center Z").as_ref(), "definition_z", d.definition_point.z), + edit(t!("Chord X").as_ref(), "chord_x", d.chord_point.x), + edit(t!("Chord Y").as_ref(), "chord_y", d.chord_point.y), + edit(t!("Chord Z").as_ref(), "chord_z", d.chord_point.z), + edit( + t!("Override Center X").as_ref(), + "override_x", + d.override_center.x, + ), + edit( + t!("Override Center Y").as_ref(), + "override_y", + d.override_center.y, + ), + edit( + t!("Override Center Z").as_ref(), + "override_z", + d.override_center.z, + ), + edit(t!("Jog X").as_ref(), "jog_x", d.jog_point.x), + edit(t!("Jog Y").as_ref(), "jog_y", d.jog_point.y), + edit(t!("Jog Z").as_ref(), "jog_z", d.jog_point.z), + edit_angle( + t!("Jog Angle").as_ref(), + "jog_angle", + d.jog_angle.to_degrees(), + ), + ], + }]; + } let compact_linear = match dim { Dimension::Linear(d) => Some(( &d.base, @@ -271,21 +312,7 @@ fn properties(dim: &Dimension) -> Vec { props.push(edit(t!("Leader 2 Y").as_ref(), "leader2_y", d.second_leader_point.y)); props.push(edit(t!("Leader 2 Z").as_ref(), "leader2_z", d.second_leader_point.z)); } - Dimension::LargeRadial(d) => { - props.push(edit(t!("Center X").as_ref(), "definition_x", d.definition_point.x)); - props.push(edit(t!("Center Y").as_ref(), "definition_y", d.definition_point.y)); - props.push(edit(t!("Center Z").as_ref(), "definition_z", d.definition_point.z)); - props.push(edit(t!("Chord X").as_ref(), "chord_x", d.chord_point.x)); - props.push(edit(t!("Chord Y").as_ref(), "chord_y", d.chord_point.y)); - props.push(edit(t!("Chord Z").as_ref(), "chord_z", d.chord_point.z)); - props.push(edit(t!("Override Center X").as_ref(), "override_x", d.override_center.x)); - props.push(edit(t!("Override Center Y").as_ref(), "override_y", d.override_center.y)); - props.push(edit(t!("Override Center Z").as_ref(), "override_z", d.override_center.z)); - props.push(edit(t!("Jog X").as_ref(), "jog_x", d.jog_point.x)); - props.push(edit(t!("Jog Y").as_ref(), "jog_y", d.jog_point.y)); - props.push(edit(t!("Jog Z").as_ref(), "jog_z", d.jog_point.z)); - props.push(edit_angle(t!("Jog Angle").as_ref(), "jog_angle", d.jog_angle.to_degrees())); - } + Dimension::LargeRadial(_) => unreachable!("handled by compact large-radial properties"), } vec![PropSection { title: t!("Geometry").into_owned(), @@ -1818,7 +1845,7 @@ pub fn style_sections( let linetype = |code, inherited| { linetype_name(document, ov::handle(data, code).unwrap_or(inherited)) }; - let arrow_1 = if matches!(dimension, Dimension::Radius(_)) { + let arrow_1 = if matches!(dimension, Dimension::Radius(_) | Dimension::LargeRadial(_)) { let inherited = if s.dimblk1.is_null() { s.dimblk } else { s.dimblk1 }; let inherited_name = if s.dimblk1.is_null() { &s.dimblk_name @@ -2520,7 +2547,10 @@ pub fn style_sections( ], }, ]; - if matches!(dimension, Dimension::Radius(_) | Dimension::Diameter(_)) { + if matches!( + dimension, + Dimension::Radius(_) | Dimension::Diameter(_) | Dimension::LargeRadial(_) + ) { let dimcen = real(ov::DIMCEN, s.dimcen); let center_type = if dimcen > 1e-12 { "Center marks" @@ -2563,8 +2593,18 @@ pub fn style_sections( "dim_ext_line_ext", "dim_ext_line_offset", ]; + const LARGE_RADIAL_LINE_FIELDS: &[&str] = &[ + "dim_arrowhead_1", + "dim_arrow_size", + "dim_line_lineweight", + "dim_line_1", + "dim_line_color", + "dim_linetype", + ]; let retained = if matches!(dimension, Dimension::Diameter(_)) { DIAMETER_LINE_FIELDS + } else if matches!(dimension, Dimension::LargeRadial(_)) { + LARGE_RADIAL_LINE_FIELDS } else { RADIUS_LINE_FIELDS }; @@ -3405,9 +3445,9 @@ fn tessellate_dimension_inner( // DIMUPT governs interactive creation-time text placement; saved // geometry already carries the resulting position. let _ = s.dimupt; - // DIMARCSYM only applies to arc-length dims; DIMJOGANG only to - // jogged-radius dims. We don't ship those Dimension variants yet, - // so the values are read for round-trip but not drawn. + // Per-entity arc-symbol and jog-angle values are consumed by their + // native dimension variants; the resolved style values remain the + // creation defaults. let _ = (s.dimarcsym, s.dimjogang); // DIMUNIT is the obsolete pre-R2000 linear unit format; DIMLUNIT // supersedes it. Read but not honoured. @@ -4199,15 +4239,23 @@ fn dimension_geometry( let override_center = lv(d.override_center); let (near, far) = jogged_radial_break(chord, jog, override_center, d.jog_angle as f32); - add_segment(&mut g.dim_lines, chord, near); - add_segment(&mut g.dim_lines, near, far); - add_segment(&mut g.dim_lines, far, override_center); + if !suppress.dim1 { + add_segment(&mut g.dim_lines, chord, near); + add_segment(&mut g.dim_lines, near, far); + add_segment(&mut g.dim_lines, far, override_center); + } append_arrow( &mut g, chord, normalized_or(near - chord, Vec3::X), arrow1, ); + append_center_mark( + &mut g, + lv(d.definition_point), + params.dimcen, + (chord - lv(d.definition_point)).length(), + ); } } g diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 62568e3d..92147a70 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -767,6 +767,12 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "DIMJOGGED: created a jogged radius dimension." => Some(("annotate", "dimjogged-created-a-jogged-radius-dimension")), "DIMJOGGED: invalid radius." => Some(("annotate", "dimjogged-invalid-radius")), "DIMJOGGED: select an arc or circle first." => Some(("annotate", "dimjogged-select-an-arc-or-circle-first")), + "DIMJOGGED Enter dimension text (blank = measured value):" => Some(("annotate", "dimjogged-enter-dimension-text-blank-measured")), + "DIMJOGGED Select arc, circle, or polyline arc:" => Some(("annotate", "dimjogged-select-arc-circle-or-polyline-arc")), + "DIMJOGGED Specify center location override:" => Some(("annotate", "dimjogged-specify-center-location-override")), + "DIMJOGGED Specify dimension line location [Mtext/Text/Angle]:" => Some(("annotate", "dimjogged-specify-dimension-line-location-text")), + "DIMJOGGED Specify jog location:" => Some(("annotate", "dimjogged-specify-jog-location")), + "DIMJOGGED Specify text angle (degrees):" => Some(("annotate", "dimjogged-specify-text-angle-degrees")), "DIMJOGLINE Select linear or aligned dimension:" => Some(("annotate", "dimjogline-select-linear-or-aligned-dimension")), "DIMJOGLINE Specify jog location:" => Some(("annotate", "dimjogline-specify-jog-location")), "DIMJOGLINE: not yet implemented — nothing changed." => Some(("common", "dimjogline-not-yet-implemented-nothing-changed")), @@ -1298,6 +1304,7 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "JUSTIFYTEXT: updated {n} text object(s)." => Some(("annotate", "justifytext-updated-n-text-object-s")), "Jog Angle" => Some(("properties", "jog-angle")), "Jog Line" => Some(("annotate", "jog-line")), + "Jogged Radius" => Some(("common", "jogged-radius")), "Jog X" => Some(("properties", "jog-x")), "Jog Y" => Some(("properties", "jog-y")), "Jog Z" => Some(("properties", "jog-z")), diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs new file mode 100644 index 00000000..598382c8 --- /dev/null +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -0,0 +1,476 @@ +use acadrust::entities::{Dimension, DimensionLargeRadial}; +use acadrust::types::{Handle, Vector3}; +use acadrust::EntityType; +use glam::{DVec3, Vec3}; + +use crate::command::{ + CadCommand, CmdOption, CmdResult, DimensionAssociationInput, DimensionAssociationSource, + WorkingPlane, +}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::creation_style::DimensionCreationDefaults; +use crate::scene::dimension_assoc::RadialSourceGeometry; +use crate::scene::model::wire_model::WireModel; +use crate::t; + +pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/dim_jog.svg")); + +pub fn tool() -> ToolDef { + ToolDef { + id: "DIMJOGGED", + label: "Jogged Radius", + icon: ICON, + event: ModuleEvent::Command("DIMJOGGED".to_string()), + } +} + +#[derive(Clone, Copy)] +enum Step { + SelectObject, + OverrideCenter(RadialSourceGeometry), + DimLine { + source: RadialSourceGeometry, + override_center: DVec3, + }, + Jog { + source: RadialSourceGeometry, + override_center: DVec3, + chord: DVec3, + text_position: DVec3, + }, +} + +pub struct JoggedRadiusDimensionCommand { + step: Step, + defaults: DimensionCreationDefaults, + editor_height: f64, + text_override: Option, + text_angle: Option, + awaiting_text: bool, + awaiting_angle: bool, + picked_entity: Option, + source_handle: Option, +} + +impl JoggedRadiusDimensionCommand { + pub fn new(defaults: DimensionCreationDefaults, annotation_multiplier: f64) -> Self { + let editor_height = defaults.text_height + * defaults.scale + * annotation_multiplier.max(f64::EPSILON); + Self { + step: Step::SelectObject, + defaults, + editor_height, + text_override: None, + text_angle: None, + awaiting_text: false, + awaiting_angle: false, + picked_entity: None, + source_handle: None, + } + } + + fn editor_anchor(&self) -> DVec3 { + match self.step { + Step::SelectObject => DVec3::ZERO, + Step::OverrideCenter(source) => dvec(source.point_at_angle(source.start_angle)), + Step::DimLine { + override_center, .. + } + | Step::Jog { + override_center, .. + } => override_center, + } + } + + fn commit_dimension( + &self, + source: RadialSourceGeometry, + override_center_world: DVec3, + chord_world: DVec3, + text_world: DVec3, + jog_world: DVec3, + ) -> CmdResult { + let plane = source_plane(source); + let center = plane.to_local(dvec(source.center_world())); + let chord = plane.to_local(chord_world); + let override_center = plane.to_local(override_center_world); + let jog = project_jog(override_center, chord, plane.to_local(jog_world)); + let text_position = plane.to_local(text_world); + + let mut dimension = DimensionLargeRadial::default(); + dimension.definition_point = v3(center); + dimension.base.definition_point = v3(center); + dimension.chord_point = v3(chord); + dimension.override_center = v3(override_center); + dimension.jog_point = v3(jog); + dimension.jog_angle = self.defaults.jog_angle; + dimension.base.style_name.clone_from(&self.defaults.style_name); + dimension.base.text_middle_point = v3(text_position); + dimension.base.insertion_point = v3(text_position); + dimension.base.text_user_positioned = true; + dimension.base.actual_measurement = dimension.measurement(); + crate::entities::dimension::set_dimension_text_override( + &mut dimension.base, + self.text_override.clone(), + ); + if let Some(angle) = self.text_angle { + dimension.base.text_rotation = angle; + } + + let association = self.source_handle.map_or_else( + || DimensionAssociationInput::Explicit(Vec::new()), + |handle| { + DimensionAssociationInput::Explicit(vec![Some( + DimensionAssociationSource::explicit( + handle, + source.marker, + source.angle_at(chord_world.to_array()), + ), + )]) + }, + ); + + CmdResult::CommitDimension { + entity: plane.place_entity(EntityType::Dimension(Dimension::LargeRadial( + dimension, + ))), + association, + } + } +} + +impl CadCommand for JoggedRadiusDimensionCommand { + fn set_working_plane(&mut self, _plane: WorkingPlane) {} + + fn name(&self) -> &'static str { + "DIMJOGGED" + } + + fn prompt(&self) -> String { + if self.awaiting_text { + return t!("DIMJOGGED Enter dimension text (blank = measured value):").into_owned(); + } + if self.awaiting_angle { + return t!("DIMJOGGED Specify text angle (degrees):").into_owned(); + } + match self.step { + Step::SelectObject => { + t!("DIMJOGGED Select arc, circle, or polyline arc:").into_owned() + } + Step::OverrideCenter(_) => { + t!("DIMJOGGED Specify center location override:").into_owned() + } + Step::DimLine { .. } => t!( + "DIMJOGGED Specify dimension line location [Mtext/Text/Angle]:" + ) + .into_owned(), + Step::Jog { .. } => t!("DIMJOGGED Specify jog location:").into_owned(), + } + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::SelectObject => CmdResult::NeedPoint, + Step::OverrideCenter(source) => { + self.step = Step::DimLine { + source, + override_center: point, + }; + CmdResult::NeedPoint + } + Step::DimLine { + source, + override_center, + } => { + self.step = Step::Jog { + source, + override_center, + chord: dvec(source.chord_at(point.to_array())), + text_position: point, + }; + CmdResult::NeedPoint + } + Step::Jog { + source, + override_center, + chord, + text_position, + } => self.commit_dimension( + source, + override_center, + chord, + text_position, + point, + ), + } + } + + fn on_enter(&mut self) -> CmdResult { + if self.awaiting_text { + self.awaiting_text = false; + return CmdResult::NeedPoint; + } + if self.awaiting_angle { + self.awaiting_angle = false; + return CmdResult::NeedPoint; + } + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn wants_text_input(&self) -> bool { + true + } + + fn point_step_accepts_keywords(&self) -> bool { + !self.awaiting_text && !self.awaiting_angle + } + + fn wants_text_with_spaces(&self) -> bool { + self.awaiting_text + } + + fn options(&self) -> Vec { + if matches!(self.step, Step::DimLine { .. }) + && !self.awaiting_text + && !self.awaiting_angle + { + vec![ + CmdOption::new("MText", "MTEXT"), + CmdOption::new("Text", "TEXT"), + CmdOption::new("Angle", "ANGLE"), + ] + } else { + Vec::new() + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + if self.awaiting_text { + let value = text.trim(); + self.text_override = if value.is_empty() || value == "<>" { + None + } else { + Some(value.to_string()) + }; + self.awaiting_text = false; + return Some(CmdResult::NeedPoint); + } + if self.awaiting_angle { + let value = text.trim(); + self.text_angle = if value.is_empty() { + None + } else { + crate::entities::common::parse_typed_angle(value) + }; + self.awaiting_angle = false; + return Some(CmdResult::NeedPoint); + } + if !matches!(self.step, Step::DimLine { .. }) { + return None; + } + match text.trim().to_ascii_uppercase().as_str() { + "T" | "TEXT" => { + self.awaiting_text = true; + Some(CmdResult::NeedPoint) + } + "M" | "MTEXT" => Some(CmdResult::SuspendForMTextInput { + pos: self.editor_anchor(), + initial: self.text_override.clone().unwrap_or_default(), + height: self.editor_height, + }), + "A" | "ANGLE" => { + self.awaiting_angle = true; + Some(CmdResult::NeedPoint) + } + _ => None, + } + } + + fn on_editor_text(&mut self, value: String) { + let value = value.trim(); + self.text_override = if value.is_empty() || value == "<>" { + None + } else { + Some(value.to_string()) + }; + } + + fn on_editor_closed(&mut self, _committed: bool) -> CmdResult { + CmdResult::NeedPoint + } + + fn needs_entity_pick(&self) -> bool { + matches!(self.step, Step::SelectObject) + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn inject_before_entity_pick(&self) -> bool { + true + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked_entity = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + let Some(entity) = self.picked_entity.take() else { + return CmdResult::NeedPoint; + }; + let Some(source) = crate::scene::dimension_assoc::radial_source_at( + &entity, + Vector3::new(point.x, point.y, point.z), + ) else { + return CmdResult::NeedPoint; + }; + if !source.radius.is_finite() || source.radius <= 1e-12 { + return CmdResult::NeedPoint; + } + self.source_handle = Some(handle); + self.step = Step::OverrideCenter(source); + CmdResult::NeedPoint + } + + fn on_mouse_move(&mut self, point: DVec3) -> Option { + match self.step { + Step::SelectObject => None, + Step::OverrideCenter(source) => Some(preview_wire(vec![ + dvec(source.center_world()).as_vec3(), + point.as_vec3(), + ])), + Step::DimLine { + source, + override_center, + } => { + let chord = dvec(source.chord_at(point.to_array())); + Some(preview_wire(vec![ + override_center.as_vec3(), + chord.as_vec3(), + Vec3::splat(f32::NAN), + chord.as_vec3(), + point.as_vec3(), + ])) + } + Step::Jog { + source, + override_center, + chord, + .. + } => { + let plane = source_plane(source); + let chord_local = plane.to_local(chord); + let override_local = plane.to_local(override_center); + let jog_local = project_jog(override_local, chord_local, plane.to_local(point)); + let (near, far) = jog_break( + chord_local, + jog_local, + override_local, + self.defaults.jog_angle, + ); + Some(preview_wire( + [chord_local, near, far, override_local] + .into_iter() + .map(|position| plane.to_world(position).as_vec3()) + .collect(), + )) + } + } + } +} + +fn source_plane(source: RadialSourceGeometry) -> WorkingPlane { + WorkingPlane::new( + DVec3::from_array(source.plane.origin), + DVec3::from_array(source.plane.x_axis), + DVec3::from_array(source.plane.y_axis), + ) +} + +fn project_jog(override_center: DVec3, chord: DVec3, point: DVec3) -> DVec3 { + let line = chord - override_center; + let length_squared = line.length_squared(); + if length_squared <= 1e-18 { + return override_center; + } + let factor = ((point - override_center).dot(line) / length_squared).clamp(0.0, 1.0); + override_center + line * factor +} + +fn jog_break( + chord: DVec3, + jog: DVec3, + override_center: DVec3, + jog_angle: f64, +) -> (DVec3, DVec3) { + let radial = (chord - override_center).normalize_or_zero(); + let (sin, cos) = jog_angle.sin_cos(); + let transverse = DVec3::new( + radial.x * cos - radial.y * sin, + radial.x * sin + radial.y * cos, + 0.0, + ) + .normalize_or_zero(); + let half = ((chord - override_center).length() * 0.04).max(1e-3); + let first = jog - transverse * half; + let second = jog + transverse * half; + if chord.distance_squared(first) <= chord.distance_squared(second) { + (first, second) + } else { + (second, first) + } +} + +fn v3(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn preview_wire(points: Vec) -> WireModel { + WireModel { + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: "dimjogged_preview".to_string(), + points: points.into_iter().map(|point| point.to_array()).collect(), + points_low: Vec::new(), + color: WireModel::CYAN, + selected: false, + pattern_length: 0.0, + pattern: [0.0; 8], + line_weight_px: 1.0, + snap_pts: Vec::new(), + tangent_geoms: Vec::new(), + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["DIMJOGGED", "DIMJOG"] +}); diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index cb6d65f1..25045cb6 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -15,6 +15,7 @@ pub mod dimspace; pub mod dimtedit; pub mod leader_cmd; pub mod linear_dim; +pub mod jogged_radius_dim; pub mod mleader_cmd; pub mod mleader_edit; pub mod mtext; @@ -103,6 +104,11 @@ impl CadModule for AnnotateModule { radius_dim::tool().label, radius_dim::tool().icon, ), + ( + jogged_radius_dim::tool().id, + jogged_radius_dim::tool().label, + jogged_radius_dim::tool().icon, + ), ( diameter_dim::tool().id, diameter_dim::tool().label, diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 9dff1b97..35032c13 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -1218,9 +1218,11 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { d.override_center, d.jog_angle, ); - result.push(make_seg(&d.chord_point, &near, &dim_c)); - result.push(make_seg(&near, &far, &dim_c)); - result.push(make_seg(&far, &d.override_center, &dim_c)); + if !met.dimsd1 { + result.push(make_seg(&d.chord_point, &near, &dim_c)); + result.push(make_seg(&near, &far, &dim_c)); + result.push(make_seg(&far, &d.override_center, &dim_c)); + } let len = ((near.x - d.chord_point.x).powi(2) + (near.y - d.chord_point.y).powi(2)) .sqrt() @@ -1232,6 +1234,15 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { &met.arrow1, &dim_c, )); + let radius = ((d.definition_point.x - d.chord_point.x).powi(2) + + (d.definition_point.y - d.chord_point.y).powi(2)) + .sqrt(); + result.extend(dim_center_mark( + d.definition_point, + met.dimcen, + radius, + &dim_c, + )); } } diff --git a/src/scene/creation_style.rs b/src/scene/creation_style.rs index ed4a1fc5..6e1cd122 100644 --- a/src/scene/creation_style.rs +++ b/src/scene/creation_style.rs @@ -18,6 +18,7 @@ pub struct DimensionCreationDefaults { pub text_height: f64, pub gap: f64, pub arrow_size: f64, + pub jog_angle: f64, pub scale: f64, pub annotative: bool, } @@ -92,6 +93,9 @@ pub fn current_dimension_defaults(doc: &CadDocument) -> DimensionCreationDefault text_height: style.as_ref().map_or(2.5, |style| style.dimtxt), gap: style.as_ref().map_or(0.625, |style| style.dimgap), arrow_size: style.as_ref().map_or(2.5, |style| style.dimasz), + jog_angle: style + .as_ref() + .map_or(std::f64::consts::FRAC_PI_4, |style| style.dimjogang), scale: style.as_ref().map_or(1.0, |style| { if style.annotative { 1.0 diff --git a/src/scene/dimension_assoc.rs b/src/scene/dimension_assoc.rs index 43901eea..8fccaceb 100644 --- a/src/scene/dimension_assoc.rs +++ b/src/scene/dimension_assoc.rs @@ -460,6 +460,7 @@ pub(crate) fn radial_extension_points( let target_point = match dimension_entity { Dimension::Radius(radius) => radius.definition_point, Dimension::Diameter(diameter) => diameter.angle_vertex, + Dimension::LargeRadial(radial) => radial.chord_point, _ => return None, }; let association = document.objects.values().find_map(|object| { @@ -556,6 +557,11 @@ impl Scene { diameter.measurement() * 0.5, diameter.angle_vertex, )), + Dimension::LargeRadial(radial) => Some(( + radial.definition_point, + radial.measurement(), + radial.chord_point, + )), _ => None, }; if let Some((center, measured_radius, chord)) = radial_data { @@ -711,6 +717,11 @@ impl Scene { diameter.measurement() * 0.5, diameter.angle_vertex, )), + Dimension::LargeRadial(radial) => Some(( + radial.definition_point, + radial.measurement(), + radial.chord_point, + )), _ => None, }; if let Some((center, radius, chord)) = radial_data { @@ -892,6 +903,33 @@ impl Scene { } diameter.base.actual_measurement = diameter.measurement(); } + Dimension::LargeRadial(radial) => { + let Some((source, angle)) = radial_source else { + continue; + }; + let old_center = radial.definition_point; + let new_center = source.center_world(); + let new_chord = source.point_at_angle(angle); + let center_delta = Vector3::new( + new_center.x - old_center.x, + new_center.y - old_center.y, + new_center.z - old_center.z, + ); + radial.definition_point = new_center; + radial.base.definition_point = new_center; + radial.chord_point = new_chord; + if point_distance_squared(center_delta, Vector3::default()) > 1e-18 { + radial.override_center = radial.override_center + center_delta; + radial.jog_point = radial.jog_point + center_delta; + if radial.base.text_user_positioned { + radial.base.text_middle_point = + radial.base.text_middle_point + center_delta; + radial.base.insertion_point = + radial.base.insertion_point + center_delta; + } + } + radial.base.actual_measurement = radial.measurement(); + } Dimension::Ordinate(ordinate) => { if let Some(feature) = resolved[0] { ordinate.feature_location = feature; From 0754c928ce73d102b2385f32a2ba423eadfc6b97 Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 26 Aug 2026 15:15:03 -0300 Subject: [PATCH 90/98] improve ALIGN command interaction and preview --- src/app/commands/inquiry.rs | 7 +- src/modules/draw/modify/align.rs | 115 ++++++++++++++++++++++++++++--- 2 files changed, 110 insertions(+), 12 deletions(-) diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index b701f91d..a49778ca 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -943,7 +943,12 @@ impl OpenCADStudio { "ALIGN" => { use crate::modules::draw::modify::align::AlignCommand; - let cmd = AlignCommand::new(); + + let selected: Vec = + self.tabs[i].scene.selected.iter().copied().collect(); + + let cmd = AlignCommand::with_selection(selected); + self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index ad6122ef..8cd4109c 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -14,6 +14,7 @@ use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdResult, EntityTransform}; +use crate::scene::model::wire_model::WireModel; pub struct AlignCommand { state: AlignState, @@ -35,10 +36,16 @@ enum AlignState { } impl AlignCommand { - pub fn new() -> Self { + pub fn with_selection(handles: Vec) -> Self { + let state = if handles.is_empty() { + AlignState::Gathering + } else { + AlignState::Src1 + }; + Self { - state: AlignState::Gathering, - handles: vec![], + state, + handles, src1: None, dst1: None, src2: None, @@ -66,11 +73,26 @@ impl CadCommand for AlignCommand { } AlignState::Dst2 => t!("ALIGN Specify 2nd destination point:").into_owned(), AlignState::AskScale => { - t!("ALIGN Scale objects based on alignment points? [Y/N]:").into_owned() + t!( + "ALIGN Scale objects based on alignment points? [Yes / No]:" + ) + .into_owned() } } } + fn options(&self) -> Vec { + use crate::command::CmdOption; + + match self.state { + AlignState::AskScale => vec![ + CmdOption::new(t!("Yes").as_ref(), "Y"), + CmdOption::new(t!("No").as_ref(), "N"), + ], + _ => vec![], + } + } + fn is_selection_gathering(&self) -> bool { self.state == AlignState::Gathering } @@ -113,14 +135,17 @@ impl CadCommand for AlignCommand { if self.handles.is_empty() { return CmdResult::Cancel; } + self.state = AlignState::Src1; CmdResult::NeedPoint } + AlignState::Src2 => { - // Only 1 pair — pure translation + // One alignment pair only: translation. match (self.src1, self.dst1) { (Some(s), Some(d)) => { let delta = d - s; + CmdResult::TransformSelected( self.handles.clone(), EntityTransform::Translate(delta), @@ -129,10 +154,10 @@ impl CadCommand for AlignCommand { _ => CmdResult::Cancel, } } - AlignState::AskScale => { - // No scale (default N) - self.compute_align(false) - } + + // Default option shown as . + AlignState::AskScale => self.compute_align(false), + _ => CmdResult::Cancel, } } @@ -145,8 +170,76 @@ impl CadCommand for AlignCommand { if self.state != AlignState::AskScale { return None; } - let scale = text.trim().to_uppercase().starts_with('Y'); - Some(self.compute_align(scale)) + + match text.trim().to_ascii_lowercase().as_str() { + "y" | "yes" | "scale" => { + Some(self.compute_align(true)) + } + + "n" | "no" | "don't scale" | "dont scale" | "noscale" => { + Some(self.compute_align(false)) + } + + _ => Some(CmdResult::NeedPoint), + } + } + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + fn line(a: DVec3, b: DVec3, name: &str) -> WireModel { + WireModel::solid( + name.into(), + vec![ + [a.x as f32, a.y as f32, a.z as f32], + [b.x as f32, b.y as f32, b.z as f32], + ], + WireModel::CYAN, + false, + ) + } + + let mut out = Vec::new(); + + // Keep the first completed alignment pair visible while defining + // the second pair and while choosing the scale option. + if let (Some(src1), Some(dst1)) = (self.src1, self.dst1) { + match self.state { + AlignState::Src2 + | AlignState::Dst2 + | AlignState::AskScale => { + out.push(line(src1, dst1, "align_pair_1")); + } + _ => {} + } + } + + match self.state { + // First source has been picked: stretch its reference line + // to the cursor until the first destination is chosen. + AlignState::Dst1 => { + if let Some(src1) = self.src1 { + out.push(line(src1, pt, "align_pair_1_preview")); + } + } + + // Second source has been picked: stretch the second reference + // line to the cursor until its destination is chosen. + AlignState::Dst2 => { + if let Some(src2) = self.src2 { + out.push(line(src2, pt, "align_pair_2_preview")); + } + } + + // Once both pairs are complete, keep both visible while + // waiting for the Scale / No Scale decision. + AlignState::AskScale => { + if let (Some(src2), Some(dst2)) = (self.src2, self.dst2) { + out.push(line(src2, dst2, "align_pair_2")); + } + } + + _ => {} + } + + out } } From cfe1ad00068878415fd1aa7a82bbeb07d61720cf Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 21:48:02 +0300 Subject: [PATCH 91/98] perf(selection): cache plugin fingerprint --- src/app/mod.rs | 2 +- src/app/update/mod.rs | 10 +---- src/scene/group_layer.rs | 5 ++- src/scene/mod.rs | 11 +++++ src/scene/selection.rs | 91 ++++++++++++++++++++++------------------ 5 files changed, 69 insertions(+), 50 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index a82dc50e..850220ff 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -679,7 +679,7 @@ pub(super) struct OpenCADStudio { /// keep their manifest listed but drop their ribbon tab and command /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. disabled_plugins: rustc_hash::FxHashSet, - /// `(tab id, selection signature)` last broadcast to V4 plugins, so + /// `(tab id, selection fingerprint)` last broadcast to V4 plugins, so /// `SelectionChangedV4` fires once per real change rather than per message. #[cfg(not(target_arch = "wasm32"))] last_plugin_selection: Option<(u64, u64)>, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2a947376..24b59350 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -233,12 +233,6 @@ impl OpenCADStudio { /// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection /// set actually changed since the last broadcast. - /// - /// Checked at the message boundary rather than at the mutation sites so - /// every path is covered: picking, window select, QSELECT, SELECTALL, - /// grip edits that drop the selection, and the automation `select` op. - /// The signature comparison keeps hover and repeated no-op selects from - /// producing spurious notifications. #[cfg(not(target_arch = "wasm32"))] pub(super) fn notify_plugins_selection_changed(&mut self) { if self.active_tab >= self.tabs.len() { @@ -246,8 +240,8 @@ impl OpenCADStudio { } let i = self.active_tab; let tab_id = self.tabs[i].id; - let sig = self.tabs[i].scene.selection_sig(); - let key = (tab_id, sig); + let fingerprint = self.tabs[i].scene.selection_fingerprint(); + let key = (tab_id, fingerprint); if self.last_plugin_selection == Some(key) { return; } diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index af957834..2c3aa78e 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -203,8 +203,11 @@ impl Scene { /// If any handle belongs to a selectable group, also select every member. pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) { + let previous_len = self.selected.len(); self.selected .extend(self.handles_expanded_for_selectable_groups(handles)); - self.bump_selection(); + if self.selected.len() != previous_len { + self.bump_selection_set(); + } } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index cf14edc6..2347fb53 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1486,6 +1486,9 @@ pub struct Scene { /// pick only refreshes the GPU xray overlay (cheap) instead of bumping /// `geometry_epoch` and re-tessellating the whole model. pub selection_generation: u64, + /// Cached fingerprint of `selected`, recomputed lazily after mutations. + selection_fingerprint_cache: u64, + selection_fingerprint_dirty: bool, /// Cached tessellation of all visible entity wires for the current layout. /// Keyed by `(geometry_epoch, camera_generation)` so a camera change /// invalidates the cull-dependent wire list as well as a geometry change. @@ -1861,6 +1864,8 @@ impl Scene { projection_bounds_epoch: std::cell::Cell::new(0), block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), selection_generation: 0, + selection_fingerprint_cache: 0, + selection_fingerprint_dirty: false, wire_cache: RefCell::new(None), interaction_index_cache: RefCell::new(Vec::new()), interaction_index_pending_key: std::cell::Cell::new(None), @@ -2610,6 +2615,11 @@ impl Scene { self.selection_generation = self.selection_generation.wrapping_add(1); } + pub(crate) fn bump_selection_set(&mut self) { + self.selection_fingerprint_dirty = true; + self.bump_selection(); + } + /// Milliseconds after the last camera change during which the view counts as /// "actively navigating" for interaction-LOD purposes. const NAV_SETTLE_MS: u128 = 130; @@ -4398,6 +4408,7 @@ impl Scene { .extend(self.selected.iter().copied()); self.selected.clear(); self.selected_order.clear(); + self.bump_selection_set(); self.bump_entities(&changes); } diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 195e071e..aa951bdd 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -40,8 +40,11 @@ impl Scene { } pub fn select_entity(&mut self, handle: Handle, exclusive: bool) { let handles = self.handles_expanded_for_leader_annotations(&[handle]); + let mut changed = false; if exclusive { + changed = self.selected.len() != handles.len() + || handles.iter().any(|handle| !self.selected.contains(handle)); self.selected.clear(); self.selected_order.clear(); } @@ -49,30 +52,35 @@ impl Scene { for handle in handles { if self.selected.insert(handle) { self.selected_order.push(handle); + changed = true; } } - self.bump_selection(); + if changed { + self.bump_selection_set(); + } } pub fn deselect_all(&mut self) { + if self.selected.is_empty() { + return; + } self.selected.clear(); self.selected_order.clear(); - self.bump_selection(); + self.bump_selection_set(); } - /// Order-independent signature of the current selection set. - /// - /// `selection_generation` cannot be used for change detection because it - /// also bumps on hover. This signature combines a per-handle hash with XOR - /// so it is allocation-free, O(n), and insensitive to selection order. - pub(crate) fn selection_sig(&self) -> u64 { - let mut combined = 0u64; - for handle in &self.selected { - let mut hasher = DefaultHasher::new(); - handle.hash(&mut hasher); - combined ^= hasher.finish(); + pub(crate) fn selection_fingerprint(&mut self) -> u64 { + if self.selection_fingerprint_dirty { + let mut fingerprint = self.selected.len() as u64; + for handle in &self.selected { + let mut hasher = DefaultHasher::new(); + handle.hash(&mut hasher); + fingerprint ^= hasher.finish(); + } + self.selection_fingerprint_cache = fingerprint; + self.selection_fingerprint_dirty = false; } - combined + self.selection_fingerprint_cache } pub(crate) fn selected_handles_in_order(&self) -> Vec { @@ -121,7 +129,7 @@ impl Scene { order.extend(added); self.selected = selected; self.selected_order = order; - self.bump_selection(); + self.bump_selection_set(); } } @@ -136,7 +144,7 @@ impl Scene { } if changed { - self.bump_selection(); + self.bump_selection_set(); } } @@ -217,7 +225,7 @@ impl Scene { } } if added > 0 { - self.bump_selection(); + self.bump_selection_set(); } added } @@ -241,7 +249,9 @@ impl Scene { self.selected_order.push(h); } } - self.bump_selection(); + if self.selected != prev { + self.bump_selection_set(); + } self.selected.len() } @@ -727,7 +737,8 @@ impl Scene { let mut handle_set: HashSet = HashSet::default(); let mut erased: Vec<(Handle, ChangeKind)> = Vec::new(); - let mut highlight_changed = false; + let mut selection_changed = false; + let mut hover_changed = false; for &h in &erase_handles { // Objects on a locked layer can't be erased. @@ -742,11 +753,11 @@ impl Scene { self.delete_solid_history(h); self.remember_removed_cache_categories(h); self.document.remove_entity_arc(h); - highlight_changed |= self.selected.remove(&h); + selection_changed |= self.selected.remove(&h); self.selected_order.retain(|selected| *selected != h); if self.hover_highlight == Some(h) { self.hover_highlight = None; - highlight_changed = true; + hover_changed = true; } self.hatches.remove(&h); self.images.remove(&h); @@ -756,7 +767,9 @@ impl Scene { handle_set.insert(h); erased.push((h, ChangeKind::Removed)); } - if highlight_changed { + if selection_changed { + self.bump_selection_set(); + } else if hover_changed { self.bump_selection(); } // Capture exactly the group objects that this erase will rewrite, plus @@ -862,25 +875,23 @@ mod tests { use super::*; #[test] - fn selection_sig_is_order_independent() { - let mut scene_a = Scene::default(); - let mut scene_b = Scene::default(); - let h1 = Handle::new(1); - let h2 = Handle::new(2); - scene_a.select_entity(h1, false); - scene_a.select_entity(h2, false); - scene_b.select_entity(h2, false); - scene_b.select_entity(h1, false); - assert_eq!(scene_a.selection_sig(), scene_b.selection_sig()); - } - - #[test] - fn selection_sig_changes_when_selection_changes() { + fn selection_fingerprint_tracks_final_set_only() { let mut scene = Scene::default(); - let empty_sig = scene.selection_sig(); - scene.select_entity(Handle::new(1), false); - assert_ne!(scene.selection_sig(), empty_sig); + let first = Handle::new(1); + let second = Handle::new(2); + scene.select_entity(first, false); + scene.select_entity(second, false); + let fingerprint = scene.selection_fingerprint(); + assert!(!scene.selection_fingerprint_dirty); + scene.deselect_all(); - assert_eq!(scene.selection_sig(), empty_sig); + scene.select_entity(second, false); + scene.select_entity(first, false); + assert!(scene.selection_fingerprint_dirty); + assert_eq!(scene.selection_fingerprint(), fingerprint); + + scene.set_hover_highlight(Some(Handle::new(3))); + assert!(!scene.selection_fingerprint_dirty); + assert_eq!(scene.selection_fingerprint(), fingerprint); } } From 4053f2c3810ed74e951fb59e4362a88b70505b8f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 21:48:02 +0300 Subject: [PATCH 92/98] docs(plugin): fix runner connection flow --- crates/ocs_plugin_api/ARCHITECTURE.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 19388eb7..20e77acc 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -61,20 +61,23 @@ The runtime enforces two gates: ```mermaid sequenceDiagram - participant H as Host process - participant PM as PluginManager - participant PP as PluginProcess + participant PM as PluginManager (host) + participant PP as PluginProcess (host) participant R as Runner child participant L as Plugin cdylib PM->>PP: spawn(cdylib_path, host) - PP->>H: spawn --ocs-plugin-runner - H->>R: exec - R->>R: create local socket listener + PP->>PP: create local socket listener + PP->>R: spawn --ocs-plugin-runner R->>L: unsafe { load(cdylib_path) } L-->>R: Box - R->>PP: connect + RunnerHandshake::Token - PP->>PP: verify OCS_PLUGIN_TOKEN + alt API v4 + R->>PP: connect + RunnerHandshake::TokenV4 + PP->>PP: verify token + V4 protocol/API gate + else API v2/v3 + R->>PP: connect + RunnerHandshake::Token + PP->>PP: verify token + end PP->>R: HostToPlugin::Request(GetManifest) R->>L: manifest() L-->>R: PluginManifest { api_version } From 79f045ce68e9269bd3705f4ecc4dd4209c2157ce Mon Sep 17 00:00:00 2001 From: gianlucafiore Date: Wed, 26 Aug 2026 15:58:36 -0300 Subject: [PATCH 93/98] feat(offset): add reference distance input --- src/modules/draw/modify/offset.rs | 88 ++++++++++++++++++++++++++----- 1 file changed, 74 insertions(+), 14 deletions(-) diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index d3b5cc0a..20a988ac 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -415,19 +415,19 @@ fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> { // ── Command implementation ───────────────────────────────────────────────── enum Step { - /// Classic first step (#418): type the offset distance, press Enter / - /// Space to accept the last one, or choose Through mode. + /// Type the offset distance, accept the previous value with Enter, + /// choose Through, or pick the first point of a reference distance. Distance, - /// Pick the object to offset. `locked == None` is "through" mode: the - /// magnitude follows the cursor (perpendicular distance to the object). + + /// First reference point has been picked; the second point defines + /// the offset distance. + ReferenceSecond { first: DVec3 }, + SelectObject { locked: Option }, + PickSide { - /// The object(s) being offset — one from a pick, or the whole - /// pre-selection when OFFSET starts with objects selected (#422). targets: Vec, locked: Option, - /// Keep the side-pick step active and use each new result as the source - /// for the next offset. multiple: bool, }, } @@ -499,6 +499,18 @@ impl OffsetCommand { } CmdResult::NeedPoint } + fn accept_distance(&mut self, distance: f64) -> CmdResult { + let distance = distance.abs().max(1e-9); + + defaults::set_offset_dist(distance); + self.advance_from_distance(Some(distance)); + + let d = format!("{:.4}", distance); + + CmdResult::ReportMeasurement( + t!("OFFSET distance = %{d}", d = d).into_owned() + ) + } } impl CadCommand for OffsetCommand { @@ -510,7 +522,15 @@ impl CadCommand for OffsetCommand { match &self.step { Step::Distance => { let d = format!("{:.4}", defaults::get_offset_dist()); - t!("OFFSET Specify offset distance or [Through] <%{d}>:", d = d).into_owned() + t!( + "OFFSET Specify offset distance or first reference point [Through] <%{d}>:", + d = d + ) + .into_owned() + } + + Step::ReferenceSecond { .. } => { + t!("OFFSET Specify second reference point:").into_owned() } Step::SelectObject { .. } => { t!("OFFSET Select object to offset (Enter to finish):").into_owned() @@ -623,7 +643,9 @@ impl CadCommand for OffsetCommand { fn dyn_field(&self) -> crate::command::DynField { match self.step { Step::Distance | Step::PickSide { .. } => crate::command::DynField::Scalar, - _ => crate::command::DynField::Point, + + Step::ReferenceSecond { .. } + | Step::SelectObject { .. } => crate::command::DynField::Point, } } @@ -648,9 +670,7 @@ impl CadCommand for OffsetCommand { return Some(self.advance_from_distance(None)); } if let Some(d) = crate::entities::common::parse_typed_length(&t) { - let d = d.abs().max(1e-9); - defaults::set_offset_dist(d); - return Some(self.advance_from_distance(Some(d))); + return Some(self.accept_distance(d)); } Some(CmdResult::NeedPoint) } @@ -698,12 +718,32 @@ impl CadCommand for OffsetCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + match &self.step { + Step::Distance => { + self.step = Step::ReferenceSecond { first: pt }; + return CmdResult::NeedPoint; + } + + Step::ReferenceSecond { first } => { + let distance = first.distance(pt); + + if distance <= 1e-9 { + return CmdResult::NeedPoint; + } + + return self.accept_distance(distance); + } + + _ => {} + } + let (locked, targets, multiple) = match &self.step { Step::PickSide { locked, targets, multiple, } => (*locked, targets.clone(), *multiple), + _ => return CmdResult::NeedPoint, }; // Each target offsets by its own through-distance (or the locked @@ -745,6 +785,26 @@ impl CadCommand for OffsetCommand { } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if let Step::ReferenceSecond { first } = &self.step { + return vec![WireModel::solid( + "offset_reference_distance".into(), + vec![ + [ + first.x as f32, + first.y as f32, + first.z as f32, + ], + [ + pt.x as f32, + pt.y as f32, + pt.z as f32, + ], + ], + WireModel::CYAN, + false, + )]; + } + let (locked, targets) = match &self.step { Step::PickSide { locked, targets, .. } => (*locked, targets.clone()), _ => return vec![], @@ -779,7 +839,7 @@ impl CadCommand for OffsetCommand { // the "repeat the same value with just Space" flow (#418). Step::Distance => { let d = defaults::get_offset_dist(); - self.advance_from_distance(Some(d.abs().max(1e-9))) + self.accept_distance(d) } _ => CmdResult::Cancel, } From 0603aae28687346eb58711dcbf6ee5c3e607c8e1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 22:21:40 +0300 Subject: [PATCH 94/98] fix(text): correct scaled text placement Derive two-point layout and continuation spacing from the effective annotation scale. Keep style flags out of per-entity mirror overrides. --- src/app/commands/draw.rs | 14 +++++++++---- src/app/properties.rs | 31 +++++++++++++++++---------- src/app/text_inline.rs | 37 ++++++++++++++++++++------------- src/command.rs | 2 ++ src/entities/text.rs | 24 ++++++++++++++++----- src/modules/annotate/text.rs | 35 ++++++++++++++++++++++++------- src/scene/convert/tessellate.rs | 9 +++++++- src/scene/creation_style.rs | 4 ---- 8 files changed, 109 insertions(+), 47 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 4c2b3814..72ccacca 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1268,10 +1268,16 @@ impl OpenCADStudio { // ── Annotate commands ────────────────────────────────────────── "TEXT" => { use crate::modules::annotate::text::TextCommand; - let document = &self.tabs[i].scene.document; - let defaults = crate::scene::creation_style::current_text_defaults(document); - let styles = document.text_styles.iter().cloned().collect(); - let new_cmd = TextCommand::with_defaults(defaults, styles); + let (defaults, styles, annotation_multiplier) = { + let scene = &self.tabs[i].scene; + let annotation_multiplier = scene.creation_annotation_multiplier(); + let defaults = + crate::scene::creation_style::current_text_defaults(&scene.document); + let styles = scene.document.text_styles.iter().cloned().collect(); + (defaults, styles, annotation_multiplier) + }; + let new_cmd = + TextCommand::with_defaults(defaults, styles, annotation_multiplier); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/properties.rs b/src/app/properties.rs index 43d3d463..fde96f93 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1809,12 +1809,30 @@ impl OpenCADStudio { text.horizontal_alignment, acadrust::entities::TextHorizontalAlignment::Aligned ); + let annotative = crate::scene::annotative::is_annotative(doc, entity); + let model_factor = if annotative { + annotation_scale_handle + .and_then(|handle| match doc.objects.get(&handle) { + Some(acadrust::objects::ObjectType::Scale(scale)) => Some( + scale.inverse_factor() + / self.tabs[i].scene.annotation_scale_unit_factor(), + ), + _ => None, + }) + .unwrap_or(self.tabs[i].scene.annotation_scale as f64) + } else { + 1.0 + }; let paper_height = if aligned { - crate::entities::text::text_run_placement(text, doc).height as f64 + crate::entities::text::text_run_placement_at_scale( + text, + doc, + model_factor as f32, + ) + .height as f64 } else { text.height }; - let annotative = crate::scene::annotative::is_annotative(doc, entity); for section in sections.iter_mut() { if let Some(row) = section.props.iter_mut().find(|row| row.field == "height") @@ -1830,15 +1848,6 @@ impl OpenCADStudio { } } if annotative { - let model_factor = annotation_scale_handle - .and_then(|handle| match doc.objects.get(&handle) { - Some(acadrust::objects::ObjectType::Scale(scale)) => Some( - scale.inverse_factor() - / self.tabs[i].scene.annotation_scale_unit_factor(), - ), - _ => None, - }) - .unwrap_or(self.tabs[i].scene.annotation_scale as f64); insert_row_after( &mut sections, "height", diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index b08297fc..4af0863f 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -234,6 +234,7 @@ impl super::OpenCADStudio { } else { crate::command::WorkingPlane::default() }; + let command_creation = ed.creation.is_some(); let mut t = if let Some(mut prepared) = ed.creation { prepared.value = ed.value.clone(); prepared @@ -258,10 +259,26 @@ impl super::OpenCADStudio { t.style = cur_style; } } - let annotative = crate::scene::annotative::text_style_is_annotative( - &self.tabs[i].scene.document, - &t.style, - ); + if command_creation { + let annotation_multiplier = if crate::scene::annotative::text_style_is_annotative( + &self.tabs[i].scene.document, + &t.style, + ) { + self.tabs[i].scene.creation_annotation_multiplier() + } else { + 1.0 + }; + let display_height = crate::entities::text::text_run_placement_at_scale( + &t, + &self.tabs[i].scene.document, + annotation_multiplier as f32, + ) + .height as f64 + * annotation_multiplier; + if let Some(command) = self.tabs[i].suspended_cmd.as_mut() { + command.on_editor_display_height(display_height); + } + } self.push_undo_snapshot(i, "TEXT"); self.tabs[i].scene.document.header.current_text_style_name = t.style.clone(); let variable_height = self.tabs[i] @@ -279,17 +296,7 @@ impl super::OpenCADStudio { { self.tabs[i].scene.document.header.text_height = t.height; } - let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); - if annotative { - let scale = self.tabs[i].scene.current_annotation_scale_handle(); - if let (Some(handle), Some(scale)) = (handle, scale) { - crate::scene::annotative::create_annotation_context( - &mut self.tabs[i].scene.document, - handle, - scale, - ); - } - } + let _ = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); self.tabs[i].dirty = true; } self.refresh_properties(); diff --git a/src/command.rs b/src/command.rs index 42d81896..c309819d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1869,6 +1869,8 @@ pub trait CadCommand: Send { /// Resume the command with collected rich text. fn on_editor_text(&mut self, _value: String) {} + fn on_editor_display_height(&mut self, _height: f64) {} + /// Called when the user clicks and `needs_entity_pick()` is true. /// `handle` is the nearest wire's entity handle (Handle::NULL if nothing found). fn on_entity_pick(&mut self, _handle: Handle, _pt: DVec3) -> CmdResult { diff --git a/src/entities/text.rs b/src/entities/text.rs index 48e1ca0e..8e4a504e 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -130,8 +130,12 @@ pub(crate) fn acad_text_encode(value: &str) -> String { out } -fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity { - let p = text_run_placement(t, document); +pub(crate) fn to_render_at_scale( + t: &Text, + document: &acadrust::CadDocument, + annotation_scale: f32, +) -> RenderEntity { + let p = text_run_placement_at_scale(t, document, annotation_scale); let snap_pt = glam::DVec3::new(p.wcs_insertion[0], p.wcs_insertion[1], p.wcs_insertion[2]); // Parse `%%` codes via acadrust, re-encoded for the stroke tessellator. let value = acad_text_encode(&p.value); @@ -172,7 +176,16 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity { /// Compute a TEXT entity's run placement (origin + layout params). Extracted /// from `to_render` verbatim so the stroke and SDF-quad paths agree exactly. -pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPlacement { +pub fn text_run_placement_at_scale( + t: &Text, + document: &acadrust::CadDocument, + annotation_scale: f32, +) -> TextPlacement { + let annotation_scale = if annotation_scale.is_finite() && annotation_scale > 1.0e-9 { + annotation_scale + } else { + 1.0 + }; let normal = (t.normal.x, t.normal.y, t.normal.z); let (wsx, wsy, wsz) = crate::scene::view::transform::ocs_point_to_wcs( ( @@ -222,7 +235,8 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla oblique_angle, ) { if base_bounds.advance > 1.0e-6 { - let scale = (span as f32 / base_bounds.advance).max(1.0e-6); + let scale = + (span as f32 / annotation_scale / base_bounds.advance).max(1.0e-6); if matches!(t.horizontal_alignment, HA::Aligned) { height *= scale; } else { @@ -605,7 +619,7 @@ fn apply_transform(t: &mut Text, tr: &EntityTransform) { impl RenderConvertible for Text { fn to_render(&self, document: &acadrust::CadDocument) -> Option { - Some(to_render(self, document)) + Some(to_render_at_scale(self, document, 1.0)) } } diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index 041394cb..90d815a6 100644 --- a/src/modules/annotate/text.rs +++ b/src/modules/annotate/text.rs @@ -47,12 +47,17 @@ pub struct TextCommand { oblique_angle: f64, fixed_height: bool, annotative: bool, - generation_flags: i16, + annotation_multiplier: f64, + last_display_height: Option, last_entity: Option, } impl TextCommand { - pub fn with_defaults(defaults: TextCreationDefaults, styles: Vec) -> Self { + pub fn with_defaults( + defaults: TextCreationDefaults, + styles: Vec, + annotation_multiplier: f64, + ) -> Self { let current_height = defaults.height; let mut command = Self { step: Step::Start, @@ -69,7 +74,8 @@ impl TextCommand { oblique_angle: defaults.oblique_angle, fixed_height: false, annotative: false, - generation_flags: 0, + annotation_multiplier: annotation_multiplier.max(1.0e-9), + last_display_height: None, last_entity: None, }; let style = command.style_name.clone(); @@ -102,8 +108,6 @@ impl TextCommand { 85.0_f64.to_radians(), ); self.annotative = style.annotative; - self.generation_flags = (if style.flags.backward { 2 } else { 0 }) - | (if style.flags.upside_down { 4 } else { 0 }); true } @@ -166,7 +170,6 @@ impl TextCommand { text.rotation = self.rotation; text.horizontal_alignment = self.horizontal; text.vertical_alignment = self.vertical; - text.generation_flags = self.generation_flags; text.alignment_point = if self.is_two_point() { let second = self.plane.to_local(self.second_point?); Some(Vector3::new(second.x, second.y, second.z)) @@ -183,6 +186,7 @@ impl TextCommand { return CmdResult::NeedPoint; }; let pos = self.first_point.unwrap_or(DVec3::ZERO); + self.last_display_height = None; self.last_entity = Some(entity.clone()); CmdResult::SuspendForTextInput { pos, entity } } @@ -199,7 +203,18 @@ impl TextCommand { } else { entity.rotation }; - let spacing = entity.height.max(1.0e-9) * 1.666_666_666_7; + let fallback_height = entity.height.max(1.0e-9) + * if self.annotative { + self.annotation_multiplier + } else { + 1.0 + }; + let spacing = self + .last_display_height + .take() + .unwrap_or(fallback_height) + .max(1.0e-9) + * 1.666_666_666_7; let delta = Vector3::new(angle.sin() * spacing, -angle.cos() * spacing, 0.0); entity.insertion_point = entity.insertion_point + delta; if let Some(point) = entity.alignment_point.as_mut() { @@ -422,6 +437,12 @@ impl CadCommand for TextCommand { } } + fn on_editor_display_height(&mut self, height: f64) { + if height.is_finite() && height > 1.0e-9 { + self.last_display_height = Some(height); + } + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d474c5f1..a50f35c4 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -633,7 +633,14 @@ pub fn tessellate( // stay a roughly constant on-screen size; otherwise the header-driven path. let te = crate::entities::point::relative_render(entity, document, world_per_pixel) .or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel)) - .or_else(|| convert(entity, document)); + .or_else(|| match entity { + EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale( + text, + document, + anno_scale, + )), + _ => convert(entity, document), + }); if let Some(te) = te { match te.object { // ── Text / MText: pre-tessellated glyph strokes ─────────────── diff --git a/src/scene/creation_style.rs b/src/scene/creation_style.rs index ed4a1fc5..bfc4ac5c 100644 --- a/src/scene/creation_style.rs +++ b/src/scene/creation_style.rs @@ -134,10 +134,6 @@ fn apply_text_defaults(doc: &CadDocument, entity: &mut EntityType) { if text.oblique_angle.abs() <= 1.0e-9 { text.oblique_angle = resolved.oblique_angle; } - if text.generation_flags == 0 { - text.generation_flags = (if resolved.flags.backward { 2 } else { 0 }) - | (if resolved.flags.upside_down { 4 } else { 0 }); - } } EntityType::MText(text) => { if resolved.height > 1.0e-9 { From e4364b4f1d6a774272181669cf08c1392926b782 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 22:57:22 +0300 Subject: [PATCH 95/98] feat(branding): update application logo --- assets/logo.svg | 169 ++++++------------------------------------------ 1 file changed, 20 insertions(+), 149 deletions(-) diff --git a/assets/logo.svg b/assets/logo.svg index e6bdb30b..7b4fdc6b 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,150 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + From 24b0fdd8e5ac820f2d05fbec4e6fcd1a94ca249f Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 26 Aug 2026 23:35:01 +0300 Subject: [PATCH 96/98] build(deps): update cadcodec revision --- Cargo.lock | 2 +- Cargo.toml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 32fa742b..f3d5fa66 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=96dee63f476cd449b95dad19ac89faf29cc19043#96dee63f476cd449b95dad19ac89faf29cc19043" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9da074c3a446759fb500c7e836bc5496c568ba84#9da074c3a446759fb500c7e836bc5496c568ba84" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index d7575f1f..579d350d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9da074c3a446759fb500c7e836bc5496c568ba84", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "96dee63f476cd449b95dad19ac89faf29cc19043" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9da074c3a446759fb500c7e836bc5496c568ba84" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } From 81e7add1ddab2ca6eef6af6f9df70fb729d5c361 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 27 Aug 2026 00:14:08 +0300 Subject: [PATCH 97/98] fix(tolerance): complete workflow integration Sync current main and companion codec changes before preserving unsupported frame rows, rendering in the entity plane, and sharing final layout metrics with placement preview. --- Cargo.lock | 2 +- Cargo.toml | 4 +- assets/logo.svg | 169 +----- crates/ocs_plugin_api/ARCHITECTURE.md | 264 ++++++++++ crates/ocs_plugin_api/README.md | 10 +- crates/ocs_plugin_api/REPORT.md | 193 ------- crates/ocs_plugin_api/src/host.rs | 12 + crates/ocs_plugin_api/src/host_v4.rs | 6 + crates/ocs_plugin_api/src/ipc/mod.rs | 15 + crates/ocs_plugin_api/src/ipc/protocol.rs | 10 + crates/ocs_plugin_api/src/ipc/transport.rs | 8 + crates/ocs_plugin_api/src/ipc/v4/client.rs | 4 +- crates/ocs_plugin_api/src/ipc/v4/mod.rs | 9 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 19 + crates/ocs_plugin_api/src/lib.rs | 21 + crates/ocs_plugin_api/src/manifest.rs | 18 +- crates/ocs_plugin_api/src/process.rs | 21 +- crates/ocs_plugin_api/src/process/manager.rs | 15 + crates/ocs_plugin_api/src/process/v4.rs | 23 +- crates/ocs_plugin_api/src/runner.rs | 12 + crates/ocs_plugin_api/src/shm.rs | 15 + crates/ocs_plugin_api/src/type_registry.rs | 10 +- crates/ocs_plugin_api/src/version_info.rs | 12 + docs/plugin-architecture.md | 4 +- src/app/automation.rs | 13 + src/app/command_driver.rs | 21 +- src/app/commands/draw.rs | 23 +- src/app/commands/inquiry.rs | 7 +- src/app/commands/layers.rs | 35 +- src/app/mod.rs | 31 ++ src/app/mtext_editor.rs | 508 ++++++++++++++++++- src/app/properties.rs | 63 +++ src/app/text_inline.rs | 83 ++- src/app/tolerance_dialog.rs | 24 +- src/app/update/command.rs | 7 +- src/app/update/file.rs | 9 +- src/app/update/mod.rs | 145 +++++- src/app/update/viewport.rs | 5 +- src/app/view/overlay.rs | 165 +++++- src/command.rs | 15 + src/entities/attribute.rs | 4 + src/entities/mtext.rs | 160 ++++-- src/entities/multileader.rs | 16 + src/entities/raster_image.rs | 2 + src/entities/table.rs | 6 + src/entities/text.rs | 237 +++++++-- src/entities/text_support.rs | 185 ++++++- src/entities/tolerance.rs | 90 ++-- src/modules/annotate/mtext.rs | 378 +++++++++++++- src/modules/annotate/text.rs | 428 +++++++++++++++- src/modules/annotate/tolerance_cmd.rs | 49 +- src/modules/draw/modify/align.rs | 115 ++++- src/modules/draw/modify/offset.rs | 88 +++- src/plugin/v4_support.rs | 9 + src/scene/cache/properties.rs | 4 +- src/scene/convert/acad_to_render.rs | 9 + src/scene/convert/tess.rs | 2 +- src/scene/convert/tessellate.rs | 253 +++++++-- src/scene/creation_style.rs | 4 - src/scene/group_layer.rs | 5 +- src/scene/mod.rs | 194 ++++--- src/scene/paper.rs | 2 +- src/scene/pipeline/text_gpu.rs | 45 ++ src/scene/selection.rs | 77 ++- src/ui/window/geometric_tolerance.rs | 250 ++++++--- 65 files changed, 3708 insertions(+), 934 deletions(-) create mode 100644 crates/ocs_plugin_api/ARCHITECTURE.md delete mode 100644 crates/ocs_plugin_api/REPORT.md diff --git a/Cargo.lock b/Cargo.lock index babff425..ec2006ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -72,7 +72,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.4.1" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=9f3cf8e#9f3cf8e26d5a02fb4ad8ea145ca8e04dd6d6f2bc" +source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=34ec2dc46a1d81fb2f41115f5c5f72037dae2572#34ec2dc46a1d81fb2f41115f5c5f72037dae2572" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 02b96ca9..0a53647a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "34ec2dc46a1d81fb2f41115f5c5f72037dae2572", features = ["serde"] } cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b2b1d4b", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" @@ -61,7 +61,7 @@ iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aa iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } [patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "9f3cf8e" } +acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "34ec2dc46a1d81fb2f41115f5c5f72037dae2572" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/assets/logo.svg b/assets/logo.svg index e6bdb30b..7b4fdc6b 100644 --- a/assets/logo.svg +++ b/assets/logo.svg @@ -1,150 +1,21 @@ - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md new file mode 100644 index 00000000..20e77acc --- /dev/null +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -0,0 +1,264 @@ +# `ocs_plugin_api` — Internal Architecture + +Internal-facing architecture documentation for `crates/ocs_plugin_api`. For plugin-author tutorials see [`README.md`](./README.md); for host/plugin integration design goals see [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md). + +--- + +## Overview + +`ocs_plugin_api` is the stable, semver-versioned contract between the Open CAD Studio host and add-on plugins. The crate is intentionally split into two tiers: + +| Tier | Feature | Dependencies | Purpose | +|------|---------|--------------|---------| +| **Core** | default | none | `PluginManifest`, `ApiVersion`, ribbon vocabulary, type registry, version info. Engine crates and external tooling can depend on this cheaply. | +| **Runtime host surface** | `host` | `acadrust`, `interprocess`, `memmap2`, `rkyv`, etc. | `HostApi`, `BuiltinPlugin`, out-of-process spawn, IPC, shared-memory snapshots, runner. | + +The host and each plugin run in separate OS processes. The host re-executes itself in `--ocs-plugin-runner` mode, the runner dynamically loads the plugin `cdylib`, and the two sides talk over a local socket. Process isolation keeps plugin crashes from affecting the host or other plugins. + +--- + +## Module map + +| Module | Responsibility | +|--------|----------------| +| [`src/manifest.rs`](src/manifest.rs) | `API_VERSION`, `API_VERSION_MIN_SUPPORTED`, `ApiVersion` compatibility, runtime `OCS_PLUGIN_MAX_API_VERSION` gate. | +| [`src/ribbon`](src/ribbon) | `CadModule` trait and plain-data ribbon types (`RibbonGroup`, `ToolDef`, `IconKind`, `ModuleEvent`). | +| [`src/host.rs`](src/host.rs) | `HostApi` plugin-facing runtime trait, `BuiltinPlugin` entry-point trait, `export_plugin!` macro, `HostNotification`, `PluginNotification`. | +| [`src/ipc/mod.rs`](src/ipc/mod.rs) | IPC layer public exports. | +| [`src/ipc/protocol.rs`](src/ipc/protocol.rs) | V2/V3 request/response enums (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`), `RunnerHandshake`, `PLUGIN_TOKEN_ENV`. | +| [`src/ipc/transport.rs`](src/ipc/transport.rs) | Length-framed bincode send/recv over `interprocess::local_socket::Stream`. | +| [`src/ipc/client.rs`](src/ipc/client.rs) | Plugin-side V2/V3 `IpcClient` and `PluginHostApi` proxy. | +| [`src/ipc/server.rs`](src/ipc/server.rs) | Host-side handler that applies a `PluginRequest` to a `HostApi` implementation. | +| [`src/ipc/v4`](src/ipc/v4) | Multiplexed V4 protocol: frames, notifications, correlation ids. | +| [`src/ipc/proxy.rs`](src/ipc/proxy.rs) | Optional TCP request proxy for plugin child processes. | +| [`src/process.rs`](src/process.rs) | `PluginProcess` lifecycle: spawn, handshake, call, timeouts, `NullHost`, io draining. | +| [`src/process/manager.rs`](src/process/manager.rs) | `PluginManager`: owns loaded plugins, dispatch routing, notification broadcast, V4 gating. | +| [`src/process/v4.rs`](src/process/v4.rs) | Host-side V4 connection and reader thread. | +| [`src/runner.rs`](src/runner.rs) | Runner entry point and V2/V3 vs V4 request loops; loads cdylib via `libloading`. | +| [`src/shm.rs`](src/shm.rs) | Shared-memory document snapshot layout, `DocumentSnapshotStore`, `SharedDocumentReader`, V3/V4 view data. | +| [`src/host_v4.rs`](src/host_v4.rs) | Host-side per-tab V4 snapshot manager. | +| [`src/type_registry.rs`](src/type_registry.rs) / [`src/type_registry_types.rs`](src/type_registry_types.rs) | Build-time `serde-reflection` registry, embedded JSON, schema types. | +| [`src/version_info.rs`](src/version_info.rs) | Embedded version metadata and acadrust source-compatibility helper. | + +--- + +## Versioning and ABI stability + +- `API_VERSION` (currently `4`) is the host's advertised major. +- `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads. +- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `3` to disable V4). +- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end. +- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)). + +The runtime enforces two gates: + +1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`. +2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true. + +--- + +## Process model + +```mermaid +sequenceDiagram + participant PM as PluginManager (host) + participant PP as PluginProcess (host) + participant R as Runner child + participant L as Plugin cdylib + + PM->>PP: spawn(cdylib_path, host) + PP->>PP: create local socket listener + PP->>R: spawn --ocs-plugin-runner + R->>L: unsafe { load(cdylib_path) } + L-->>R: Box + alt API v4 + R->>PP: connect + RunnerHandshake::TokenV4 + PP->>PP: verify token + V4 protocol/API gate + else API v2/v3 + R->>PP: connect + RunnerHandshake::Token + PP->>PP: verify token + end + PP->>R: HostToPlugin::Request(GetManifest) + R->>L: manifest() + L-->>R: PluginManifest { api_version } + R-->>PP: HostResponse::Manifest + PP->>PP: verify api_version + acadrust gate + alt accepted + PP-->>PM: success, keep alive + else rejected + PP->>R: Shutdown + PP-->>PM: error + end +``` + +Key invariants: + +- The runner process is the host binary re-executed with special CLI args, so runner and host share the same `ocs_plugin_api` build. +- The plugin cdylib is `dlopen`-ed inside the runner, not the host. +- A pre-shared token (`OCS_PLUGIN_TOKEN`) authenticates the runner to the host. +- `PluginProcess` owns the `Child` handle and a reader thread; `PluginManager` owns the loaded plugins. + +--- + +## Wire protocols + +### V2/V3: request/response over a single socket + +A single bidirectional socket carries both directions. While the host waits for a response to a `HostRequest`, it may receive nested `PluginRequest`s from the runner and handles them inline. + +```mermaid +sequenceDiagram + participant H as Host + participant R as Runner + participant P as BuiltinPlugin + + H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" }) + R->>P: dispatch(host, "LINE") + P->>R: PluginToHost::Request(AddEntity(...)) + R->>H: HostToPlugin::Request(AddEntity(...)) + H-->>R: PluginToHost::Response(Handle) + R-->>P: PluginResponse::Handle + P-->>R: true + R-->>H: PluginToHost::Response(Bool(true)) +``` + +Compatibility rule: new variants are appended at the end of `HostRequest`, `HostResponse`, `PluginRequest`, and `PluginResponse`, preserving bincode discriminant indices for old plugins. + +### V4: multiplexed frames + +V4 keeps the V3 request vocabulary but multiplexes requests, responses, and best-effort notifications over one local socket. Every request/response carries a correlation `id`; notifications carry an optional `command_id`. + +```mermaid +sequenceDiagram + participant H as Host + participant R as V4 Runner + participant P as BuiltinPlugin + + H->>R: HostToPluginV4::Notification(DocumentChangedV4 { tab_id }) + H->>R: HostToPluginV4::Request { id: 7, ExecuteCode } + R->>P: start_execute_code(host, 7, ...) + P-->>R: true + P->>R: Notification(Output { text: "..." }) + R->>H: PluginToHostV4::Notification(Output) + P->>R: Response { id: 7, result } + R->>H: PluginToHostV4::Response { id: 7, CodeExecutionResult } +``` + +Key types in [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs): + +- `HostToPluginV4`: `Request { id, HostRequest }`, `Response { id, PluginResponse }`, `Notification`. +- `PluginToHostV4`: `Request { id, tab_id, PluginRequest }`, `Response { id, HostResponse }`, `Notification`. +- `NotificationEnvelope`: `{ command_id: Option, payload: T }`. + +Notifications are best-effort: per-process errors are logged, not propagated. Unknown host-notification discriminants deserialize to `HostNotification::Unknown(raw)` so a newer host does not crash an older plugin. + +--- + +## Shared-memory document views + +V3 and V4 avoid cloning the full `CadDocument` over IPC for large reads. The host owns a memory-mapped file; the plugin maps it read-only. + +```mermaid +sequenceDiagram + participant P as Plugin + participant R as Runner + participant H as Host + participant S as DocumentSnapshotStore + + P->>R: request(OpenDocumentView) + R->>H: PluginRequest::OpenDocumentView + H->>S: DocumentSnapshotStore::new / publish + S-->>H: DocumentViewInfo { path, version } + H-->>R: PluginResponse::DocumentView { path, version } + R-->>P: DocumentViewInfo + P->>S: SharedDocumentReader::open(path) + S-->>P: read-only mapping + loop host publishes new version + H->>S: publish(data) + S->>S: swap active segment, increment version + P->>S: read() + check version + end + P->>P: close reader + H->>S: close(tab_id) +``` + +- [`src/shm.rs`](src/shm.rs) defines the generic double-buffered control page and the `SnapshotData` trait. Implementations live in the same file for V3 (`DocumentViewData`) and V4 (`DocumentViewDataV4`). +- [`src/host_v4.rs`](src/host_v4.rs) keeps a global `HostV4SnapshotManager` keyed by `tab_id`, so each document tab has its own snapshot. +- Segment size defaults to 16 MiB and is configurable via `OCS_V4_SNAPSHOT_SEGMENT_SIZE` (minimum 1 MiB). + +--- + +## Notifications + +- **Host → plugin:** `HostNotification` in [`src/host.rs`](src/host.rs). V4-only delivery via `PluginManager::broadcast_notification`. Currently emitted examples: `DocumentChangedV4`, `SelectionChangedV4`, `DocumentTabClosed`. +- **Plugin → host:** `PluginNotification` in [`src/host.rs`](src/host.rs). Examples: `Output`, `Progress`, `Log`. Handler installed via `PluginManager::set_notification_handler`. + +Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 processes are skipped. The handler runs on the V4 reader thread and must not block. + +--- + +## Error handling and timeouts + +| Kind | Default | Env var | Behavior | +|------|---------|---------|----------| +| Spawn / connect timeout | 30 s | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | Host aborts spawn, plugin is not loaded. | +| Per-call timeout | 30 s | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | Host kills runner, plugin marked dead. Floor timeouts apply to `GetManifest`/`GetRibbon`, `Dispatch`, and interactive events. | +| Oversized message | — | — | Transport rejects messages > 64 MiB with `TransportError::TooLarge`. | +| Malformed message | — | — | `bincode` deserialize error; V4 reader logs and continues if possible. | +| Panic in plugin code | — | — | Caught by `catch_unwind` in the runner; converted to `HostResponse::Error`. | +| Dead runner | — | — | Detected via `try_wait` on next dispatch or ribbon rebuild; plugin dropped. | + +--- + +## Embedded metadata + +The crate embeds two JSON blobs at build time: + +- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). +- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). + +Both are accessible without enabling the `host` feature. + +--- + +## Security / isolation boundaries + +- **Process isolation:** plugin code runs in a child process. +- **Token handshake:** `OCS_PLUGIN_TOKEN` is generated per spawn and checked before accepting the runner connection. +- **C-ABI export:** plugins expose only two symbols, `ocs_plugin_api_version` and `ocs_plugin_register`, defined by `export_plugin!`. +- **Message size cap:** 64 MiB per length-framed message. +- **Shared memory:** plugin maps snapshots read-only; the host controls publish/close. + +This is defense-in-depth, not a sandbox: plugins execute native code. + +--- + +## Failure modes + +| Scenario | Result | +|----------|--------| +| Plugin panics during dispatch | Runner catches, returns error response, process stays alive. | +| Call timeout | Host kills runner, plugin marked dead. | +| Spawn timeout or crash | Spawn fails; plugin not loaded; error surfaced in UI/logs. | +| Version mismatch | Host refuses plugin before running plugin code. | +| Acadrust source mismatch (v4+) | Host refuses plugin to avoid binary incompatibilities. | +| Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. | + +--- + +## Open tasks + +- **Reduce broadcast notification clone/serialize overhead.** `PluginManager::broadcast_notification` clones the `HostNotification` (including its `Vec`) once per loaded V4 plugin, and `transport::send` re-serializes the payload into a new `Vec` per plugin. Fixing this properly requires either a public API change (`Arc>` in `HostNotification`) or a new bytes-oriented broadcast path in the IPC layer. It only matters with many plugins plus very large selections. + +--- + +## Cross-references + +- Plugin-author quick start: [`README.md`](./README.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) +- Plugin template: [`../../docs/plugin-template/`](../../docs/plugin-template/) +- Process spawn: [`src/process.rs`](src/process.rs) +- Process manager: [`src/process/manager.rs`](src/process/manager.rs) +- Runner loop: [`src/runner.rs`](src/runner.rs) +- V4 protocol: [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs) +- Shared memory: [`src/shm.rs`](src/shm.rs) diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index 93951816..25021c8e 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -120,9 +120,10 @@ The frame format supports: - Bi-directional, best-effort notifications (`NotificationEnvelope`) Because the socket is full-duplex, the host can push -`HostNotification::DocumentChanged` or `InputLine` to a plugin at any time, and -the plugin can stream `PluginNotification::Output`/`Progress`/`Log` back while a -long command is still running. +`HostNotification::DocumentChangedV4`, `SelectionChangedV4`, or +`DocumentTabClosed` to a plugin at any time, and the plugin can stream +`PluginNotification::Output`/`Progress`/`Log` back while a long command is still +running. ```mermaid sequenceDiagram @@ -420,7 +421,8 @@ std::thread::spawn(move || { ### Further reading +- Internal architecture & invariants: [`ARCHITECTURE.md`](./ARCHITECTURE.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) - Plugin template: [`../../docs/plugin-template`](../../docs/plugin-template) - Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md) - REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md) -- Python REPL plugin: [`../ocs_python_repl/README.md`](../ocs_python_repl/README.md) diff --git a/crates/ocs_plugin_api/REPORT.md b/crates/ocs_plugin_api/REPORT.md deleted file mode 100644 index 1509fd1c..00000000 --- a/crates/ocs_plugin_api/REPORT.md +++ /dev/null @@ -1,193 +0,0 @@ -# Out-of-Process Plugin Architecture — `ocs_plugin_api` - -**Status:** Design proposal -**Scope:** `crates/ocs_plugin_api` only (minimal host wiring, no plugin changes, no new crate) - ---- - -## 1. Problem - -The host currently loads external add-ons as `cdylib` libraries into its own process via `libloading`. `panic::catch_unwind` catches Rust panics, but a plugin can still corrupt host memory, segfault, or deadlock the UI thread. The fix is to run each plugin as a separate OS process and mediate all interaction through IPC. - ---- - -## 2. Design - -`ocs_plugin_api` becomes a dual-use library: - -- **Plugin side:** unchanged API surface (`BuiltinPlugin`, `HostApi`, `CadModule`, `export_plugin!`). -- **Host side:** runtime that spawns plugin processes and handles their IPC requests. - -Plugins remain `cdylib`s. The host spawns **itself** in runner mode (`--ocs-plugin-runner `) to load each cdylib in a child process and bridge to the host over `interprocess::local_socket`. The runner implementation lives inside `ocs_plugin_api` as a library module; no separate helper binary is needed, so the runner and host are always the same build and cannot get out of sync at deployment time. - ---- - -## 3. Constraints - -| Constraint | Handling | -|---|---| -| Plugin API unchanged | Trait/type signatures preserved. `document()` / `document_mut()` keep their signatures but return a local cached copy, so `API_VERSION` bumps to 3. | -| Only `ocs_plugin_api` modified | All new code lives here. The host needs only minimal call-site wiring in `src/plugin/external.rs`, `src/plugin/registry.rs`, and `src/app/plugin_host.rs`. | -| No new crate | Runner code lives inside `ocs_plugin_api`; the host executable serves as the runner process. | -| Platform-independent | `interprocess::local_socket` uses named pipes on Windows and Unix domain sockets elsewhere; self-spawning works on every host target. | - ---- - -## 4. Architecture - -```text -Host process Plugin process -┌─────────────────┐ local socket ┌─────────────────┐ -│ HostSession │◄───────────────►│ HostApi proxy │ -│ (document, UI) │ bincode frames │ (sends RPCs) │ -└─────────────────┘ └─────────────────┘ - ▲ │ - │ ▼ - PluginManager cdylib loaded - (spawn / kill / by host in - supervise) runner mode -``` - -### 4.1 IPC protocol - -All messages are length-framed and serialized with `bincode`. - -**Host → plugin:** - -- `GetManifest`, `GetRibbon` -- `Dispatch { cmd: String }` -- `InteractiveEvent { command_id, event }` -- `Shutdown` - -**Plugin → host:** - -- `PushInfo` / `PushOutput` / `PushError` -- `AddEntity(SerializedEntity)` → `Handle` -- `BumpGeometry`, `PushUndo`, `SetDirty` -- `ReadRecord`, `WriteRecord`, `RemoveRecord` -- `StartInteractive`, `PollInteractive` -- `DocumentSnapshot` → `SerializedDocument` - -`SerializedEntity`, `SerializedRecord`, and `SerializedDocument` are `acadrust` types with `Serialize` / `Deserialize` derived. - -### 4.2 Plugin-side runtime - -`OpenCADStudio --ocs-plugin-runner `: - -1. Loads the cdylib, validates `ocs_plugin_api_version`, calls `ocs_plugin_register`. -2. Connects to the host socket and answers `GetManifest` / `GetRibbon`. -3. Runs a request loop: dispatch commands, forward interactive events. - -`PluginHostApi` implements `HostApi` by sending RPCs. `document()` / `document_mut()` return a local copy fetched from `DocumentSnapshot`; mutations are **not** automatically synced back. Plugins use `add_entity`, `write_record`, etc. for host-visible changes. - -### 4.3 Host-side runtime - -`ocs_plugin_api::process` provides: - -- `PluginProcess::spawn(cdylib_path)` — creates a socket, launches the host executable in runner mode, accepts its connection. -- `PluginManager` — spawn all discovered plugins, supervise, kill. -- `serve_plugin_connection(stream, &mut dyn HostApi)` — host-side request handler. - -The host creates a `HostSession` and passes it to `serve_plugin_connection`; existing document/undo logic is reused. - -### 4.4 Ribbon - -Ribbon types use `&'static str`, which cannot cross a socket. Define owned equivalents in `ocs_plugin_api::ribbon::owned` and convert for IPC. The host reconstructs `RibbonGroup` once per load (e.g., by leaking the owned strings), avoiding changes to ribbon rendering code. - -### 4.5 Failure handling - -| Failure | Behavior | -|---|---| -| Plugin crash / hang / malformed message | Host marks plugin dead, drops its ribbon tab, logs the error, and continues running. | -| Plugin panics | Caught inside the runner; an error response is returned to the host. | -| Spawn failure | Reported through `PluginManager` and shown in the Plugin Manager. | - ---- - -## 5. Host Integration Points - -1. **`src/plugin/external.rs`** — replace `libloading`-based `LoadedPlugin` with `PluginProcess::spawn`. -2. **`src/plugin/registry.rs`** — use `PluginProcess` for ribbon collection and command dispatch. -3. **`src/app/plugin_host.rs`** — add an IPC request bridge that maps incoming messages to `HostSession` calls. - -No changes to `docs/plugin-template` or any other plugin. - ---- - -## 6. Crate Changes - -### 6.1 New files inside `crates/ocs_plugin_api` - -```text -src/ - ipc/ - protocol.rs # HostRequest / PluginRequest / PluginResponse - transport.rs # framed read/write over local_socket - client.rs # plugin-side IpcClient + PluginHostApi - server.rs # host-side serve_plugin_connection - process.rs # PluginProcess / PluginManager - runner.rs # plugin runner logic invoked by host in runner mode -``` - -### 6.2 Dependencies - -Add under the existing `host` feature: - -```toml -[dependencies] -interprocess = { version = "2", optional = true } -serde = { version = "1", features = ["derive"], optional = true } -bincode = { version = "1", optional = true } -thiserror = { version = "1", optional = true } -libloading = { version = "0.8", optional = true } - -[features] -host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"] -``` - ---- - -## 7. API Version - -Bump `API_VERSION` to `3` because `document()` / `document_mut()` semantics change from direct host references to local cached copies. v2 plugins are refused as usual; plugin authors recompile with `ApiVersion::CURRENT`. - ---- - -## 8. Implementation Plan - -1. Add dependencies to `Cargo.toml`. -2. Implement framed transport and protocol messages. -3. Implement runner logic in `runner.rs`. -4. Implement `PluginHostApi` proxy. -5. Implement host-side server and `PluginManager`. -6. Add owned ribbon conversions. -7. Wire the three host call sites and add `--ocs-plugin-runner` dispatch in `src/main.rs`. -8. Bump `API_VERSION` and update `docs/plugin-architecture.md`. - ---- - -## 9. Testing - -- **Unit:** protocol round-trip, ribbon conversion, proxy request emission. -- **Integration:** spawn a test plugin, verify dispatch and interactive command round-trip, kill the process and confirm the host survives. -- **Host:** update registry tests once `LoadedPlugin` is replaced by `PluginProcess`. - ---- - -## 10. Compliance with `AGENT.md` - -| Requirement | Status | -|---|---| -| `ocs_plugin_api` is a library, not a plugin | Yes | -| Plugin API source-compatible | Yes; signatures are unchanged. `document()` semantics change is gated by v3. | -| Separate processes + failure management | Yes | -| Platform-independent `interprocess` IPC | Yes | -| Only `ocs_plugin_api` modified | Code: yes. Host needs minimal wiring; unavoidable because `OpenCADStudio` / `HostSession` are host-private. | -| No new crate | Yes | -| Memory / process isolation | Yes | - ---- - -## 11. Summary - -`ocs_plugin_api` absorbs the plugin runtime: the host spawns itself in runner mode to load each cdylib in its own process, and all host/plugin interaction is serialized over `interprocess` local sockets. Plugin API signatures stay intact, host changes are limited to a few call sites, and no new crate is introduced. diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index eb2220b9..f146c23a 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -69,6 +69,8 @@ pub enum HostNotification { DocumentChangedV4 { tab_id: u64, version: u64 }, /// V4 tab closed notification. Discriminant 6. DocumentTabClosed { tab_id: u64 }, + /// V4 selection changed for a specific tab. Discriminant 7. + SelectionChangedV4 { tab_id: u64, handles: Vec }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -112,6 +114,13 @@ impl Serialize for HostNotification { bincode::serialize_into(&mut bytes, tab_id) .map_err(serde::ser::Error::custom)?; } + HostNotification::SelectionChangedV4 { tab_id, handles } => { + bytes.push(7); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, handles) + .map_err(serde::ser::Error::custom)?; + } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) @@ -146,6 +155,9 @@ impl<'de> Deserialize<'de> for HostNotification { 6 => bincode::deserialize(rest) .map(|tab_id| HostNotification::DocumentTabClosed { tab_id }) .map_err(serde::de::Error::custom), + 7 => bincode::deserialize(rest) + .map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles }) + .map_err(serde::de::Error::custom), _ => Ok(HostNotification::Unknown(bytes)), } } diff --git a/crates/ocs_plugin_api/src/host_v4.rs b/crates/ocs_plugin_api/src/host_v4.rs index c6341338..3674ea8f 100644 --- a/crates/ocs_plugin_api/src/host_v4.rs +++ b/crates/ocs_plugin_api/src/host_v4.rs @@ -3,6 +3,12 @@ //! Maintains a per-tab shared-memory document view keyed by the stable //! `DocumentTab.id`. The manager is used by `src/plugin/v4_support.rs` to //! open, publish, and close V4 snapshots independently of the V3 reader path. +//! +//! [`manager`] returns a global `Mutex`-guarded [`HostV4SnapshotManager`]. +//! [`HostV4SnapshotManager::open`] lazily creates a [`DocumentSnapshotStore`] for +//! a tab; [`HostV4SnapshotManager::publish`] updates an existing store; +//! [`HostV4SnapshotManager::close`] drops it. Segment size defaults to 16 MiB +//! and can be overridden with `OCS_V4_SNAPSHOT_SEGMENT_SIZE`. use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index 5ea05e15..44e7e02e 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -2,6 +2,21 @@ //! //! Built only with the `host` feature because it needs `acadrust`-typed //! messages and the plugin runner binary. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::protocol) — V2/V3 request/response enums and the +//! initial runner handshake. +//! - [`transport`](crate::ipc::transport) — length-framed bincode over a local +//! socket; enforces `MAX_MESSAGE_SIZE`. +//! - [`client`](crate::ipc::client) — plugin-side V2/V3 client and `HostApi` +//! proxy. +//! - [`server`](crate::ipc::server) — host-side handler that applies a +//! `PluginRequest` to a `HostApi` implementation. +//! - [`v4`](crate::ipc::v4) — multiplexed V4 frames, notifications, and +//! correlation ids. +//! - [`proxy`](crate::ipc::proxy) — optional TCP proxy so plugin child +//! processes can forward requests to the host. #[cfg(feature = "host")] pub mod client; diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index c8735a30..5b494026 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -4,6 +4,16 @@ //! (expecting a response) or a response (to a previous request). This lets the //! host handle plugin RPCs inline while it waits for the result of a host→plugin //! request such as `Dispatch`, avoiding the need for two sockets or threads. +//! +//! Compatibility rule: new variants are appended at the end of every public enum +//! (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`, +//! `RunnerHandshake`) so older plugins keep their bincode discriminant indices. +//! The V4 additions are a separate frame layer in [`crate::ipc::v4`] and do not +//! alter these enums. +//! +//! The pre-shared runner token is delivered through [`PLUGIN_TOKEN_ENV`] +//! (`OCS_PLUGIN_TOKEN`). The runner must present the same token immediately +//! after connecting or the host closes the connection. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/ipc/transport.rs b/crates/ocs_plugin_api/src/ipc/transport.rs index 69e42ce6..3cc1296d 100644 --- a/crates/ocs_plugin_api/src/ipc/transport.rs +++ b/crates/ocs_plugin_api/src/ipc/transport.rs @@ -1,4 +1,12 @@ //! Length-framed transport over `interprocess::local_socket` streams. +//! +//! Messages are serialized with `bincode`, prefixed by a little-endian `u64` +//! length, and sent over the stream. The receiver parses the length, bounds it +//! against [`MAX_MESSAGE_SIZE`], then deserializes the payload. +//! +//! [`send`] and [`recv`] are synchronous and block until the full frame is read +//! or the peer disconnects. `Disconnected` is returned on clean EOF; `TooLarge` +//! rejects messages that exceed 64 MiB to protect host/runner memory. use std::io::{Read, Write}; diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 6eda5605..a491a6eb 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -761,7 +761,6 @@ impl DocumentReader for EmptyDocumentReader { #[cfg(all(test, feature = "host"))] mod tests { use std::sync::atomic::{AtomicU64, Ordering}; - use std::sync::Mutex as StdMutex; use std::thread; use interprocess::local_socket::{ @@ -772,10 +771,9 @@ mod tests { use super::*; use crate::ipc::transport::recv; use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4}; + use crate::test_lock::ENV_LOCK; use acadrust::entities::Point; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - fn unique_socket_name() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); let n = COUNTER.fetch_add(1, Ordering::Relaxed); diff --git a/crates/ocs_plugin_api/src/ipc/v4/mod.rs b/crates/ocs_plugin_api/src/ipc/v4/mod.rs index e67788bd..3a27ac7e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/mod.rs @@ -4,6 +4,15 @@ //! requests, responses, and best-effort notifications over a single local //! socket. The V3 files (`src/ipc/{protocol,client,server}.rs`) are left //! untouched; all V4 logic lives in this module. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::v4::protocol) — V4 wire frames, notification +//! envelopes, and correlation ids. +//! - [`client`](crate::ipc::v4::client) — plugin-side V4 client loop and +//! notification dispatch. +//! - [`server`](crate::ipc::v4::server) — host-side V4 connection and reader +//! thread. pub mod client; pub mod protocol; diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 2d339b5d..34e97c2e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -1,4 +1,23 @@ //! V4 wire frames and envelope types. +//! +//! V4 replaces the simple request/response pipe with a multiplexed frame layer. +//! Every request and response carries a monotonically increasing correlation +//! `id` so a plugin can send/receive out of order. Notifications carry an +//! optional `command_id` so a plugin can associate a host notification with the +//! long-running command that caused it. +//! +//! The two frame enums are: +//! +//! - [`HostToPluginV4`] — host → runner requests, responses to runner requests, +//! and host notifications. +//! - [`PluginToHostV4`] — runner → host requests, responses to host requests, +//! and plugin notifications. +//! +//! Notifications are wrapped in [`NotificationEnvelope`] and are best-effort: +//! a process that fails to accept a notification is logged and skipped, but the +//! connection stays alive. Unknown host-notification discriminants deserialize +//! to [`crate::host::HostNotification::Unknown`] so newer host notifications do +//! not break older plugins. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index e721983e..9b430922 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -20,6 +20,17 @@ //! For binary compatibility, the host and every plugin must resolve the same //! `acadrust` source. The host does this via a `[patch.crates-io]` entry in //! `Cargo.toml`; out-of-tree plugins should copy that exact patch. +//! +//! For internal architecture (process model, wire protocols, versioning policy, +//! failure modes), see `ARCHITECTURE.md` in the crate root. The modules enabled +//! by the `host` feature are: +//! +//! - `host` — plugin/runtime traits and notification types. +//! - `host_v4` — per-tab shared-memory snapshot manager. +//! - `ipc` — transport, V2/V3 protocol, and V4 multiplexed protocol. +//! - `process` — `PluginProcess` and `PluginManager`. +//! - `runner` — child-process runner entry point. +//! - `shm` — shared-memory document views. pub mod manifest; pub mod ribbon; @@ -64,5 +75,15 @@ pub use type_registry::{ }; pub use version_info::get_embedded_version_info_json; +#[cfg(test)] +pub(crate) mod test_lock { + //! Shared lock for tests that mutate process environment variables. + //! + //! Environment variables are global mutable state. Any test that sets or + //! removes an env var must hold this lock for the duration of the mutation + //! so tests in other modules do not observe half-written state. + pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +} + #[cfg(feature = "host")] pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess}; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 0d459341..39352e8a 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -1,4 +1,16 @@ -//! Plugin identity and capability declaration. +//! Plugin identity, API version constants, and compatibility rules. +//! +//! This module defines the host ABI major version (`API_VERSION`), the oldest +//! major the host still loads (`API_VERSION_MIN_SUPPORTED`), and the runtime +//! `OCS_PLUGIN_MAX_API_VERSION` gate that lets operators disable newer API +//! majors without rebuilding. +//! +//! Compatibility rule: a plugin compiled against major `N` runs on any host +//! whose major is `>= N`, because new vtable entries and enum variants are +//! appended at the end. `host_accepts_plugin_version` enforces the supported +//! range after applying the runtime cap. +//! +//! See `ARCHITECTURE.md` in the crate root for the full versioning policy. /// Host plugin API version. Bump when the host runtime surface breaks /// compatibility. v2 added `HostApi::start_interactive`. v3 changes @@ -56,12 +68,10 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool { #[cfg(test)] mod tests { - use std::sync::Mutex; + use crate::test_lock::ENV_LOCK; use super::*; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - #[test] fn current_matches_const() { assert_eq!(ApiVersion::CURRENT.major, API_VERSION); diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 9a26d958..7535af35 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -1,4 +1,22 @@ //! Process management for out-of-process plugins. +//! +//! This module defines the lifecycle of a single plugin process: +//! +//! 1. `PluginProcess::spawn` creates a local socket, spawns the host binary in +//! runner mode (`--ocs-plugin-runner `), and waits for the +//! runner to connect back. +//! 2. The runner presents a pre-shared token via [`crate::ipc::protocol::PLUGIN_TOKEN_ENV`]; +//! the host rejects the connection on mismatch. +//! 3. The host requests the manifest, checks `api_version` and (for v4+) the +//! acadrust source gate, then keeps the process alive. +//! 4. Host → plugin calls (`dispatch`, `execute_code`, interactive events) are +//! sent over the socket with a configurable per-call timeout. +//! 5. Stdout/stderr of the child are drained into `PluginIoLine` records for +//! logging and UI display. +//! +//! `NullHost` is a dummy `HostApi` implementation used for V4 paths that do not +//! supply a real host surface (e.g., interactive events). Timeouts and message +//! size limits are centralized here. use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -1119,6 +1137,7 @@ mod timeout_tests { }; use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedPluginManifest; + use crate::test_lock::ENV_LOCK; use acadrust::xdata::ExtendedDataRecord; use acadrust::{CadDocument, EntityType, Handle}; use interprocess::local_socket::{ @@ -1130,8 +1149,6 @@ mod timeout_tests { use std::thread; use std::time::Instant; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - struct EmptyReader; impl DocumentReader for EmptyReader { diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index d2fec1e4..003f6ff0 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -1,4 +1,19 @@ //! Process manager for out-of-process plugins. +//! +//! `PluginManager` is the host's owner of every loaded plugin process. It is +//! responsible for: +//! +//! - Spawning plugins via [`PluginProcess::spawn`] and building their ribbon +//! modules. +//! - Routing commands to plugins through [`PluginManager::dispatch`]. +//! - Broadcasting host notifications to every alive V4 plugin via +//! [`PluginManager::broadcast_notification`]. +//! - Surfacing dead plugins and per-plugin errors in [`DispatchResult`]. +//! +//! Notifications are V4-only: `broadcast_notification` skips non-V4 processes. +//! The plugin-to-host notification handler (set via +//! [`PluginManager::set_notification_handler`]) runs on the V4 reader thread +//! and must not block. For heavy work, forward the notification to a channel. use std::path::Path; use std::sync::Arc; diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 9843c3bb..d2fc6015 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -517,7 +517,7 @@ mod tests { use std::sync::Mutex as StdMutex; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + use crate::test_lock::ENV_LOCK; fn set_test_env() { std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2"); @@ -532,6 +532,13 @@ mod tests { #[test] fn execute_code_timeout_floor_is_at_least_60s_and_env_override() { let _guard = ENV_LOCK.lock().unwrap(); + let prev_call_timeout = std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS").ok(); + let prev_test_floor = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS").ok(); + let prev_execute_timeout = std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS").ok(); + // Remove the test floor bypass and the call-timeout override so the + // ExecuteCode-specific floor (60 s) is actually exercised. + std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"); + std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"); std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); assert!( request_timeout("ExecuteCode") >= Duration::from_secs(60), @@ -544,7 +551,19 @@ mod tests { Duration::from_secs(120), "env override should be respected" ); - std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); + + match prev_call_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"), + } + match prev_test_floor { + Some(v) => std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"), + } + match prev_execute_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"), + } } #[test] diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 6b4a60c6..03f0097f 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -4,6 +4,18 @@ //! (`--ocs-plugin-runner `). Keeping the runner code inside //! `ocs_plugin_api` means the host only needs to know the CLI contract, not the //! internal plugin-loading and IPC details. +//! +//! Entry points and loops: +//! +//! - [`run`] — loads the cdylib with `libloading`, connects back to the host, +//! and dispatches to either the V2/V3 loop or the V4 loop based on the plugin's +//! declared API version. +//! - `run_v3` — synchronous request/response loop over [`crate::ipc::client::IpcClient`]. +//! - `run_v4` — multiplexed frame loop over [`crate::ipc::v4::client::V4Client`]; +//! drains notifications and dispatches host requests. +//! +//! Panics inside plugin callbacks are caught by `catch_unwind` and converted to +//! error responses so a buggy plugin callback does not tear down the runner. use std::cell::RefCell; use std::collections::HashMap; diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index ebada4e5..4203ce40 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -8,6 +8,21 @@ //! This module is generic over the payload type so that the V3 simplified view //! and the V4 bincode-per-entity view can share the same double-buffered //! control-page logic. +//! +//! Key types: +//! +//! - [`DocumentSnapshotStore`] — host-side, file-backed double buffer. Call +//! [`DocumentSnapshotStore::publish`] to atomically swap the active segment and +//! increment the version. +//! - [`SharedDocumentReader`] — plugin-side read-only mapping. +//! - [`DocumentViewInfo`] — path + version returned to the plugin so it can +//! open the mapping. +//! - [`DocumentViewData`] / [`DocumentViewDataV4`] — concrete snapshot payloads +//! for V3 and V4. +//! +//! The control page at the start of the mapping stores magic, version, active +//! segment, and length. The two snapshot segments follow it. Publishing writes +//! to the inactive segment, then flips the active segment atomically. use std::fs::OpenOptions; use std::io; diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs index a563d715..ce55abce 100644 --- a/crates/ocs_plugin_api/src/type_registry.rs +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -3,7 +3,15 @@ //! The JSON embedded here is produced by tracing a curated allow-list of //! `acadrust` types with `serde-reflection` and mapping the result into a //! stable, language-binding-friendly schema defined in -//! [`crate::type_registry_types`]. +//! [`crate::type_registry_types`]. The registry is embedded via +//! `include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"))` so the +//! `host` feature is not required to read it. +//! +//! The allow-list is intentionally narrow: it excludes generic containers and +//! internal enums such as `EntityType` in favor of concrete serializable structs +//! like `Point`, `Line`, `Circle`, and `MText`. Language bindings and tooling +//! can depend on this schema without pulling the full `acadrust` dependency +//! tree. pub use crate::type_registry_types::*; diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 1df0bc30..35c870cd 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -1,4 +1,16 @@ //! Embedded version metadata generated at build time. +//! +//! At compile time `build.rs` writes a small JSON blob to +//! `OUT_DIR/version_info.json` containing host version, `ocs_plugin_api` +//! version, `acadrust` version and source, API version bounds, and a build +//! timestamp. The blob is embedded via `include_str!` and is available without +//! enabling the `host` feature. +//! +//! The acadrust source string is used to detect binary-incompatible plugin +//! builds. For API v4 and later the host compares the plugin's resolved +//! `acadrust` source with its own via [`acadrust_sources_compatible`]. Two +//! sources are considered compatible only when they resolve to the same 40 +//! character git commit hash. use std::sync::OnceLock; diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index b3a5e5a4..dc1a8d0a 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -127,6 +127,7 @@ concrete types: | Command line | `push_info`, `push_output`, `push_error` | | Undo / dirty | `push_undo`, `set_dirty` | | Tab | `tab_index()` | +| Notifications (V4) | `on_notification` receives `HostNotification::SelectionChangedV4 { tab_id, handles }` when the active tab's selection changes, plus `DocumentChangedV4` / `DocumentTabClosed`. | ### `export_plugin!` — the C-ABI export @@ -309,7 +310,7 @@ Two timeouts protect the host from a stuck runner: | Timeout | Env var | Default | Floor | |---|---|---|---| -| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 10 s | — | +| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 30 s | — | | Per-call | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | 30 s | `GetManifest`/`GetRibbon` ≥ 5 s, `Dispatch` ≥ 10 s, interactive events/prompt/pick ≥ 2 s | A call timeout covers the full round-trip, including any nested plugin→host @@ -402,6 +403,7 @@ Next: | Piece | Location | |-------|----------| | Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | +| Internal API architecture | [`crates/ocs_plugin_api/ARCHITECTURE.md`](../crates/ocs_plugin_api/ARCHITECTURE.md) | | Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) | | Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../crates/ocs_plugin_api/src/process.rs) | | Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` | diff --git a/src/app/automation.rs b/src/app/automation.rs index f72c3048..fddac688 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -193,6 +193,17 @@ fn entity_json(e: &acadrust::EntityType) -> Value { impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. pub(crate) fn automation_op(&mut self, line: &str) -> Value { + let res = self.automation_op_inner(line); + // Most automation ops mutate `Scene::selected` directly rather than + // going through `update()` (`select` calls `deselect_all` / + // `select_entity`, and `run` can erase the selected entities), so the + // selection check has to run on this path too. + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); + res + } + + fn automation_op_inner(&mut self, line: &str) -> Value { let req: Value = match serde_json::from_str(line) { Ok(v) => v, Err(e) => return err(format!("invalid JSON: {e}")), @@ -201,6 +212,7 @@ impl OpenCADStudio { "new" => { let i = self.active_tab; self.tabs[i].scene.document = acadrust::CadDocument::new(); + self.tabs[i].scene.deselect_all(); self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which // blocks drawing commands; turn it into a real drawing. @@ -224,6 +236,7 @@ impl OpenCADStudio { Ok(doc) => { let i = self.active_tab; self.tabs[i].scene.document = doc; + self.tabs[i].scene.deselect_all(); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, ); diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index bd57247d..09eb0971 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3716,10 +3716,11 @@ impl OpenCADStudio { handle, initial, height, + template, } => { self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; - self.open_mtext_editor(pos, handle, &initial, height); + self.open_mtext_editor(pos, handle, &initial, height, template.map(|m| *m)); } CmdResult::SuspendForMTextInput { pos, @@ -3732,7 +3733,7 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); self.command_mtext_input = true; self.pending_command_editor_text = None; - self.open_mtext_editor(pos, None, &initial, height); + self.open_mtext_editor(pos, None, &initial, height, None); } CmdResult::OpenTextEditor { pos, @@ -3748,6 +3749,22 @@ impl OpenCADStudio { &initial, height, super::text_inline::TextEntityField::Text, + None, + ); + } + CmdResult::SuspendForTextInput { pos, entity } => { + self.tabs[i].suspended_cmd = self.tabs[i].active_cmd.take(); + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + let height = entity.height; + self.open_text_inline( + pos, + None, + "", + height, + super::text_inline::TextEntityField::Text, + Some(entity), ); } CmdResult::EditTextEntity { handle } => { diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 065f2e62..d9edbc86 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1268,11 +1268,16 @@ impl OpenCADStudio { // ── Annotate commands ────────────────────────────────────────── "TEXT" => { use crate::modules::annotate::text::TextCommand; - let height = crate::scene::creation_style::current_text_defaults( - &self.tabs[i].scene.document, - ) - .height; - let new_cmd = TextCommand::with_height(height); + let (defaults, styles, annotation_multiplier) = { + let scene = &self.tabs[i].scene; + let annotation_multiplier = scene.creation_annotation_multiplier(); + let defaults = + crate::scene::creation_style::current_text_defaults(&scene.document); + let styles = scene.document.text_styles.iter().cloned().collect(); + (defaults, styles, annotation_multiplier) + }; + let new_cmd = + TextCommand::with_defaults(defaults, styles, annotation_multiplier); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } @@ -1307,7 +1312,13 @@ impl OpenCADStudio { &self.tabs[i].scene.document, ) .height; - let new_cmd = MTextCommand::with_height(height); + let style = self.tabs[i] + .scene + .document + .header + .current_text_style_name + .clone(); + let new_cmd = MTextCommand::with_defaults(height, style); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/app/commands/inquiry.rs b/src/app/commands/inquiry.rs index b701f91d..a49778ca 100644 --- a/src/app/commands/inquiry.rs +++ b/src/app/commands/inquiry.rs @@ -943,7 +943,12 @@ impl OpenCADStudio { "ALIGN" => { use crate::modules::draw::modify::align::AlignCommand; - let cmd = AlignCommand::new(); + + let selected: Vec = + self.tabs[i].scene.selected.iter().copied().collect(); + + let cmd = AlignCommand::with_selection(selected); + self.command_line.push_info(&cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(cmd)); } diff --git a/src/app/commands/layers.rs b/src/app/commands/layers.rs index a0220654..9dfc1e77 100644 --- a/src/app/commands/layers.rs +++ b/src/app/commands/layers.rs @@ -827,30 +827,8 @@ impl OpenCADStudio { } } - // A viewport frames the model, so its framing is measured in model - // units and has to follow them. Left alone, every layout would suddenly - // look at a region a thousand times too large. The paper side of the - // ratio is untouched — the sheet is still the same sheet — so the ratio - // itself moves the other way. - // - // `transform_viewport` covers the rectangle and the target but not the - // framing, so the framing is done here for every viewport, and the - // target only for the ones the scale above did not already reach. - // Not every viewport frames the model, though. Each layout's sheet - // viewport frames the sheet — its view is the paper, measured in paper - // units — so scaling it by the model's factor would leave the drawing - // frame the same size as before while the view of it changed by a - // thousand, which is the boundary appearing to break. - let sheets: std::collections::HashSet = self.tabs[i] - .scene - .document - .objects - .values() - .filter_map(|object| match object { - acadrust::objects::ObjectType::Layout(layout) => Some(layout.viewport), - _ => None, - }) - .collect(); + // Model framing follows model units; sheet framing stays in paper units. + let sheets = self.tabs[i].scene.sheet_viewport_handles(); let scaled: std::collections::HashSet = handles.iter().copied().collect(); let mut reframed = 0usize; for entity in self.tabs[i].scene.document.entities_mut() { @@ -858,14 +836,7 @@ impl OpenCADStudio { let acadrust::entities::EntityType::Viewport(vp) = entity else { continue; }; - // Only a paper-space viewport can be a sheet — the scale above - // reached everything in model space, and what it reached frames the - // model by definition. Among the rest, the layout names its sheet - // outright, and a file that arrives without that link still gives - // itself away by sitting at the paper origin, where only the sheet - // sits. - let is_sheet = !scaled.contains(&handle) - && (sheets.contains(&handle) || !crate::scene::Scene::is_content_viewport(vp)); + let is_sheet = !scaled.contains(&handle) && sheets.contains(&handle); if is_sheet { continue; } diff --git a/src/app/mod.rs b/src/app/mod.rs index d9c5bc13..b18e5301 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -694,6 +694,10 @@ pub(super) struct OpenCADStudio { /// keep their manifest listed but drop their ribbon tab and command /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. disabled_plugins: rustc_hash::FxHashSet, + /// `(tab id, selection fingerprint)` last broadcast to V4 plugins, so + /// `SelectionChangedV4` fires once per real change rather than per message. + #[cfg(not(target_arch = "wasm32"))] + last_plugin_selection: Option<(u64, u64)>, /// External add-on packages found in the plugins folder, refreshed when the /// Plugin Manager opens. external_plugins: Vec, @@ -2667,6 +2671,31 @@ pub enum Message { MTextWidth(String), /// Toolbar character-spacing field changed. MTextCharSpace(String), + /// Undo / redo editor text and inline-format operations. + MTextUndo, + MTextRedo, + /// Convert the selected numerator/separator/denominator to a stacked run. + MTextStack, + /// Remove inline character formatting from the selection (or all text). + MTextClearFormatting, + /// Insert a predefined symbol or field token at the caret. + MTextInsert(String), + /// Per-object annotation flag edited from the text toolbar. + MTextAnnotative(bool), + /// Column layout controls. + MTextColumnMode(String), + MTextColumnCount(String), + MTextColumnWidth(String), + MTextColumnGutter(String), + MTextColumnHeight(String), + MTextColumnFlowReversed(bool), + /// Paragraph indent/spacing controls. + MTextParagraphNumber(mtext_editor::ParaNumber, String), + MTextFindText(String), + MTextReplaceText(String), + MTextFindNext, + MTextReplaceNext, + MTextReplaceAll, /// Toolbar colour picker (same widget as Properties) — applies to the /// selection, or the whole text when nothing is selected. MTextColorChanged(AcadColor), @@ -3259,6 +3288,8 @@ impl OpenCADStudio { attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute, attr_editor_selected: 0, disabled_plugins: rustc_hash::FxHashSet::default(), + #[cfg(not(target_arch = "wasm32"))] + last_plugin_selection: None, external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), plugin_load_errors: rustc_hash::FxHashMap::default(), diff --git a/src/app/mtext_editor.rs b/src/app/mtext_editor.rs index f5dae78e..ad519605 100644 --- a/src/app/mtext_editor.rs +++ b/src/app/mtext_editor.rs @@ -8,7 +8,7 @@ use acadrust::entities::mtext::AttachmentPoint; use acadrust::entities::mtext_format::{ parse_mtext, MTextColor, MTextDocument, MTextFont, MTextParagraph, MTextParagraphAlignment, - MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, + MTextScalar, MTextSpan, ParagraphProperties, SpanProperties, StackingData, StackingType, }; use acadrust::types::Vector3; use acadrust::{EntityType, Handle, MText}; @@ -38,6 +38,15 @@ pub enum ParaAlign { Justify, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ParaNumber { + FirstIndent, + LeftIndent, + RightIndent, + SpaceBefore, + SpaceAfter, +} + /// `pick_list`-friendly wrapper for the 9 attachment points. #[derive(Clone, Copy, PartialEq, Eq)] pub struct JustifyChoice(pub AttachmentPoint); @@ -107,6 +116,23 @@ pub struct MTextEditorState { /// it is NOT derived from the typed content, so adding characters wraps /// to the next line instead of stretching the box into one long line. pub rect_width: f64, + pub rect_height: String, + pub annotative: bool, + pub column_type: i16, + pub column_count: String, + pub column_auto_height: bool, + pub column_flow_reversed: bool, + pub column_width: String, + pub column_gutter: String, + pub paragraph_first_indent: String, + pub paragraph_left_indent: String, + pub paragraph_right_indent: String, + pub paragraph_space_before: String, + pub paragraph_space_after: String, + pub find_text: String, + pub replace_text: String, + undo: Vec, + redo: Vec, /// `Some` when editing an existing entity; `None` for a fresh MText. pub editing: Option, /// When true the panel shows the rendered preview; when false the raw @@ -167,6 +193,23 @@ impl MTextEditorState { // Default box ~20 characters wide; overwritten with the entity's // own width when editing an existing MText. rect_width: (height * 20.0).max(1.0), + rect_height: "0".to_string(), + annotative: false, + column_type: 0, + column_count: "2".to_string(), + column_auto_height: false, + column_flow_reversed: false, + column_width: (height * 10.0).to_string(), + column_gutter: (height * 5.0).to_string(), + paragraph_first_indent: "0".to_string(), + paragraph_left_indent: "0".to_string(), + paragraph_right_indent: "0".to_string(), + paragraph_space_before: "0".to_string(), + paragraph_space_after: "0".to_string(), + find_text: String::new(), + replace_text: String::new(), + undo: Vec::new(), + redo: Vec::new(), editing, screen_anchor: iced::Point::new(60.0, 90.0), original: None, @@ -274,8 +317,54 @@ impl MTextEditorState { mt.attachment_point = self.attachment; mt.line_spacing_factor = self.line_spacing as f64; mt.style = self.style.clone(); + mt.is_annotative = self.annotative; + let rectangle_height = self.rect_height.trim().parse::().unwrap_or(0.0); + mt.rectangle_height = (rectangle_height > 0.0).then_some(rectangle_height); + mt.column_data.column_type = self.column_type; + if self.column_type == 0 { + mt.column_data.column_count = 0; + mt.column_data.heights.clear(); + } else { + mt.column_data.column_count = crate::entities::text_support::clamp_mtext_column_count( + self.column_count + .trim() + .parse::() + .ok() + .filter(|count| *count > 0) + .unwrap_or(1), + ); + mt.column_data.auto_height = self.column_auto_height; + mt.column_data.flow_reversed = self.column_flow_reversed; + if let Ok(width) = self.column_width.trim().parse::() { + if width > 0.0 { + mt.column_data.width = width; + } + } + if let Ok(gutter) = self.column_gutter.trim().parse::() { + if gutter >= 0.0 { + mt.column_data.gutter = gutter; + } + } + if !self.column_auto_height && rectangle_height > 0.0 { + mt.column_data.heights = vec![ + rectangle_height; + mt.column_data.column_count as usize + ]; + } + } mt } + + fn record_undo(&mut self) { + let value = self.content.text(); + if self.undo.last().is_none_or(|last| last != &value) { + self.undo.push(value); + if self.undo.len() > 100 { + self.undo.remove(0); + } + } + self.redo.clear(); + } } /// Parse a numeric field, returning `Some(v)` only when it differs from the @@ -595,6 +684,27 @@ fn vertical_caret_target( best.1 } +fn seed_mtext_state(state: &mut MTextEditorState, m: &MText) { + state.attachment = m.attachment_point; + state.line_spacing = m.line_spacing_factor as f32; + state.rect_width = m.rectangle_width.max(0.0); + state.rect_height = m.rectangle_height.unwrap_or(0.0).max(0.0).to_string(); + state.annotative = m.is_annotative; + state.column_type = m.column_data.column_type; + state.column_count = crate::entities::text_support::clamp_mtext_column_count( + m.column_data.column_count, + ) + .to_string(); + state.column_auto_height = m.column_data.auto_height; + state.column_flow_reversed = m.column_data.flow_reversed; + state.column_width = m.column_data.width.max(0.0).to_string(); + state.column_gutter = m.column_data.gutter.max(0.0).to_string(); + if !m.style.trim().is_empty() { + state.style = m.style.clone(); + } + state.original = Some(m.clone()); +} + impl super::OpenCADStudio { /// Open the in-place editor for a new (`handle = None`) or existing MText. /// Open the rich MText editor for a new or existing MText / MultiLeader. @@ -605,6 +715,7 @@ impl super::OpenCADStudio { handle: Option, initial: &str, height: f64, + template: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -614,20 +725,11 @@ impl super::OpenCADStudio { state.screen_anchor = p; } // Seed attachment / line-spacing / box width from the entity being edited. + let has_template = template.is_some(); if let Some(h) = handle { match self.tabs[self.active_tab].scene.document.get_entity(h) { Some(EntityType::MText(m)) => { - state.attachment = m.attachment_point; - state.line_spacing = m.line_spacing_factor as f32; - if !m.style.trim().is_empty() { - state.style = m.style.clone(); - } - if m.rectangle_width > 0.0 { - state.rect_width = m.rectangle_width; - } - // Keep the whole entity so `build_mtext` preserves the fields - // the toolbar can't edit (columns, rotation, background…). - state.original = Some(m.clone()); + seed_mtext_state(&mut state, m); } Some(EntityType::MultiLeader(ml)) => { state.line_spacing = ml.context.line_spacing_factor as f32; @@ -637,6 +739,8 @@ impl super::OpenCADStudio { } _ => {} } + } else if let Some(m) = template { + seed_mtext_state(&mut state, &m); } else { // New MText inherits the document's current text style (STYLE), // not the "Standard" default. See #92. @@ -659,7 +763,7 @@ impl super::OpenCADStudio { // The preview uses a fixed on-screen text size. Therefore the initial // wrap width is the drawing-unit span that exactly reaches the right // edge of the editor, placing both ruler and slider at their maximum. - if handle.is_none() { + if handle.is_none() && !has_template { let initial_width = self.mtext_editor.as_ref().map(|ed| { super::view::overlay::MTEXT_EDITOR_WRITING_WIDTH / ed.preview_scale() }); @@ -746,6 +850,322 @@ impl super::OpenCADStudio { /// Splice text around the preview selection (visible-char range) in the /// raw value. `case` optionally transforms the selected slice. Returns /// true when a preview selection was present and applied. + pub(super) fn mtext_undo(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + if let Some(previous) = ed.undo.pop() { + ed.redo.push(ed.content.text()); + ed.content = text_editor::Content::with_text(&previous); + ed.caret = previous.chars().count(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_redo(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + if let Some(next) = ed.redo.pop() { + ed.undo.push(ed.content.text()); + ed.content = text_editor::Content::with_text(&next); + ed.caret = next.chars().count(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_span_number(&mut self, field: &'static str, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + let valid = match field { + "height" => number > 0.0, + "oblique" => (-85.0..=85.0).contains(&number), + "width" => number > 0.0, + "tracking" => number.is_finite(), + _ => false, + }; + if !valid { + return; + } + let has_selection = self + .mtext_editor + .as_ref() + .and_then(|editor| editor.sel) + .is_some_and(|(start, end)| start < end); + if let Some(ed) = self.mtext_editor.as_mut() { + if has_selection { + let (start, end) = ed.sel.unwrap(); + ed.record_undo(); + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + match field { + "height" => props.height = Some(MTextScalar::Absolute(number)), + "oblique" => props.oblique_angle = (number != 0.0).then_some(number), + "width" => props.width_factor = (number != 1.0).then_some(number), + "tracking" => props.tracking = (number != 0.0).then_some(number), + _ => {} + } + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } else { + match field { + "height" => ed.height = value, + "oblique" => ed.oblique = value, + "width" => ed.width = value, + "tracking" => ed.char_space = value, + _ => {} + } + } + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_apply_paragraph_number(&mut self, field: ParaNumber, value: String) { + let Some(number) = value.trim().replace(',', ".").parse::().ok() else { + return; + }; + if !number.is_finite() { + return; + } + if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); + match field { + ParaNumber::FirstIndent => ed.paragraph_first_indent = value, + ParaNumber::LeftIndent => ed.paragraph_left_indent = value, + ParaNumber::RightIndent => ed.paragraph_right_indent = value, + ParaNumber::SpaceBefore => ed.paragraph_space_before = value, + ParaNumber::SpaceAfter => ed.paragraph_space_after = value, + } + let para0 = doc_para0(&ed.doc); + let cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((ed.caret, ed.caret)); + let paragraph_of = |index: usize| { + cells + .iter() + .take(index.min(cells.len())) + .filter(|cell| matches!(cell, Cell::Break(..))) + .count() + }; + let first = paragraph_of(start); + let last = paragraph_of(end.saturating_sub((end > start) as usize)); + let mut doc = cells_to_doc(¶0, &cells); + for paragraph in doc + .paragraphs + .iter_mut() + .skip(first) + .take(last.saturating_sub(first) + 1) + { + let optional = (number != 0.0).then_some(number); + match field { + ParaNumber::FirstIndent => paragraph.properties.first_line_indent = optional, + ParaNumber::LeftIndent => paragraph.properties.left_margin = optional, + ParaNumber::RightIndent => paragraph.properties.right_margin = optional, + ParaNumber::SpaceBefore => paragraph.properties.spacing_before = optional, + ParaNumber::SpaceAfter => paragraph.properties.spacing_after = optional, + } + } + ed.content = text_editor::Content::with_text(&doc.to_mtext_string()); + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_stack_selection(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let Some((start, end)) = ed.sel.filter(|(start, end)| start < end) else { + return; + }; + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let end = end.min(cells.len()); + let plain = cells_to_doc(&ParagraphProperties::default(), &cells[start..end]) + .to_plain_text(); + let Some((split, separator)) = plain + .char_indices() + .find(|(_, ch)| matches!(ch, '/' | '#' | '^')) + else { + return; + }; + let separator_len = separator.len_utf8(); + let data = StackingData { + numerator: plain[..split].to_string(), + denominator: plain[split + separator_len..].to_string(), + stacking_type: StackingType::from_char(separator), + }; + if data.numerator.is_empty() || data.denominator.is_empty() { + return; + } + let props = cells[start..end] + .iter() + .find_map(|cell| match cell { + Cell::Char(_, props) | Cell::Stack { props, .. } => Some(props.clone()), + Cell::Break(..) => None, + }) + .unwrap_or_default(); + ed.record_undo(); + let replacement = flatten_stack(&data) + .chars() + .enumerate() + .map(|(index, _)| Cell::Stack { + data: data.clone(), + props: props.clone(), + head: index == 0, + }) + .collect::>(); + let replacement_len = replacement.len(); + cells.splice(start..end, replacement); + let new_end = start + replacement_len; + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, new_end)); + ed.caret = new_end; + ed.sel_anchor = start; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_clear_formatting(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let (start, end) = ed + .sel + .filter(|(start, end)| start < end) + .unwrap_or((0, cells.len())); + let end = end.min(cells.len()); + ed.record_undo(); + for cell in &mut cells[start.min(end)..end] { + if let Cell::Char(_, props) | Cell::Stack { props, .. } = cell { + *props = SpanProperties::default(); + } + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.sel = Some((start, end)); + ed.caret = end; + } + self.rebuild_mtext_preview(); + } + + pub(super) fn mtext_find_next(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let cells = doc_to_cells(&ed.doc); + let chars: Vec> = cells + .iter() + .map(|cell| match cell { + Cell::Char(ch, _) => Some(*ch), + Cell::Break(..) => Some('\n'), + Cell::Stack { .. } => None, + }) + .collect(); + if needle.len() > chars.len() { + return; + } + let start = ed.caret.min(chars.len()); + let last_start = chars.len() - needle.len(); + let found = (start..=last_start) + .chain(0..start.min(last_start + 1)) + .find(|index| { + chars[*index..*index + needle.len()] + .iter() + .zip(&needle) + .all(|(actual, expected)| actual == &Some(*expected)) + }); + if let Some(found) = found { + ed.sel_anchor = found; + ed.sel = Some((found, found + needle.len())); + ed.caret = found + needle.len(); + ed.caret_blink_on = true; + } + } + } + + pub(super) fn mtext_replace_next(&mut self) { + let replacement = self + .mtext_editor + .as_ref() + .map(|editor| editor.replace_text.clone()) + .unwrap_or_default(); + let matches = self.mtext_editor.as_ref().is_some_and(|editor| { + let Some((start, end)) = editor.sel.filter(|(start, end)| start < end) else { + return false; + }; + let cells = doc_to_cells(&editor.doc); + cells_to_doc( + &ParagraphProperties::default(), + &cells[start.min(cells.len())..end.min(cells.len())], + ) + .to_plain_text() + == editor.find_text + }); + if matches { + self.mtext_type(&replacement); + } + self.mtext_find_next(); + } + + pub(super) fn mtext_replace_all(&mut self) { + if let Some(ed) = self.mtext_editor.as_mut() { + let needle: Vec = ed.find_text.chars().collect(); + if needle.is_empty() { + return; + } + let para0 = doc_para0(&ed.doc); + let mut cells = doc_to_cells(&ed.doc); + let replacement = ed.replace_text.clone(); + let mut matches = Vec::new(); + let mut index = 0usize; + while index + needle.len() <= cells.len() { + let found = cells[index..index + needle.len()] + .iter() + .zip(&needle) + .all(|(cell, expected)| matches!(cell, Cell::Char(actual, _) if actual == expected)); + if found { + matches.push(index); + index += needle.len(); + } else { + index += 1; + } + } + if matches.is_empty() { + return; + } + ed.record_undo(); + for start in matches.into_iter().rev() { + let props = insert_props(&cells, start); + let paragraph = para_props_at(¶0, &cells, start); + let replacement_cells = str_to_cells(&replacement, &props, ¶graph); + cells.splice(start..start + needle.len(), replacement_cells); + } + ed.content = text_editor::Content::with_text( + &cells_to_doc(¶0, &cells).to_mtext_string(), + ); + ed.caret = cells.len(); + ed.sel = Some((ed.caret, ed.caret)); + ed.sel_anchor = ed.caret; + } + self.rebuild_mtext_preview(); + } + /// Toggle a character format over the preview selection, on the structured /// span properties. The stroke-font renderer has no true bold, so Bold /// switches the run to the heavier "Gothic" face; Italic applies a 15° @@ -798,6 +1218,7 @@ impl super::OpenCADStudio { if a >= b { return; } + ed.record_undo(); match kind { MTextFmt::Underline => { let on = !all_have(&cells[a..b], |p| p.underline()); @@ -882,6 +1303,7 @@ impl super::OpenCADStudio { /// Set the alignment of every paragraph the selection (or caret) touches. pub(super) fn mtext_apply_align(&mut self, align: ParaAlign) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let cells = doc_to_cells(&ed.doc); let (a, b) = ed @@ -933,6 +1355,7 @@ impl super::OpenCADStudio { s }; if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); // Edit the structured document as a flat cell list, then serialize // it back into the raw content the preview/commit still read. let para0 = doc_para0(&ed.doc); @@ -962,6 +1385,7 @@ impl super::OpenCADStudio { /// Delete the selection, or the visible character before the caret. pub(super) fn mtext_backspace(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); @@ -985,6 +1409,7 @@ impl super::OpenCADStudio { /// Delete the selection, or the visible character at the caret. pub(super) fn mtext_delete(&mut self) { if let Some(ed) = self.mtext_editor.as_mut() { + ed.record_undo(); let para0 = doc_para0(&ed.doc); let mut cells = doc_to_cells(&ed.doc); let count = cells.len(); @@ -1104,6 +1529,7 @@ impl super::OpenCADStudio { let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { + ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { let (bold, italic) = p @@ -1155,6 +1581,7 @@ impl super::OpenCADStudio { let mut cells = doc_to_cells(&ed.doc); let b = b.min(cells.len()); if a < b { + ed.record_undo(); for c in &mut cells[a..b] { if let Cell::Char(_, p) | Cell::Stack { props: p, .. } = c { p.color = mcolor.clone(); @@ -1217,7 +1644,8 @@ impl super::OpenCADStudio { } let body_empty = ed.content.text().trim().is_empty(); let mut mt = ed.build_mtext(); - let annotative = ed.editing.is_none() + let annotative = mt.is_annotative + || ed.editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, @@ -1240,9 +1668,12 @@ impl super::OpenCADStudio { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; + t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; + t.rectangle_height = mt.rectangle_height; + t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; @@ -1255,9 +1686,25 @@ impl super::OpenCADStudio { _ => {} } // Keep the displayed annotation context in sync. + if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + h, + annotative, + ); + if annotative { + if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + h, + scale, + ); + } + } + } if matches!( self.tabs[i].scene.document.get_entity(h), - Some(EntityType::MultiLeader(_)) + Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene @@ -1283,7 +1730,6 @@ impl super::OpenCADStudio { position.y, position.z, ); - mt.rotation = 0.0; self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); if annotative { @@ -1321,7 +1767,8 @@ impl super::OpenCADStudio { ), None => return, }; - let annotative = editing.is_none() + let annotative = mt.is_annotative + || editing.is_none() && crate::scene::annotative::text_style_is_annotative( &self.tabs[i].scene.document, &mt.style, @@ -1341,9 +1788,12 @@ impl super::OpenCADStudio { Some(EntityType::MText(t)) => { t.value = mt.value; t.height = mt.height; + t.style = mt.style; t.attachment_point = mt.attachment_point; t.line_spacing_factor = mt.line_spacing_factor; t.rectangle_width = mt.rectangle_width; + t.rectangle_height = mt.rectangle_height; + t.column_data = mt.column_data; } Some(EntityType::MultiLeader(ml)) => { ml.context.text_string = mt.value; @@ -1355,9 +1805,25 @@ impl super::OpenCADStudio { } _ => {} } + if matches!(self.tabs[i].scene.document.get_entity(h), Some(EntityType::MText(_))) { + crate::scene::annotative::set_entity_annotative( + &mut self.tabs[i].scene.document, + h, + annotative, + ); + if annotative { + if let Some(scale) = self.tabs[i].scene.current_annotation_scale_handle() { + crate::scene::annotative::create_annotation_context( + &mut self.tabs[i].scene.document, + h, + scale, + ); + } + } + } if matches!( self.tabs[i].scene.document.get_entity(h), - Some(EntityType::MultiLeader(_)) + Some(EntityType::MText(_) | EntityType::MultiLeader(_)) ) { self.tabs[i] .scene @@ -1383,7 +1849,6 @@ impl super::OpenCADStudio { position.y, position.z, ); - mt.rotation = 0.0; self.push_undo_snapshot(i, "MTEXT"); let handle = self.commit_entity_handle(plane.place_entity(EntityType::MText(mt))); self.tabs[i].dirty = true; @@ -1400,6 +1865,11 @@ impl super::OpenCADStudio { // Bind the editor to the fresh entity so the next Apply updates it. if let (Some(h), Some(ed)) = (handle, self.mtext_editor.as_mut()) { ed.editing = Some(h); + if let Some(EntityType::MText(mtext)) = + self.tabs[i].scene.document.get_entity(h) + { + ed.original = Some(mtext.clone()); + } } } self.refresh_properties(); diff --git a/src/app/properties.rs b/src/app/properties.rs index 5d55c28d..b405dfab 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1884,6 +1884,69 @@ impl OpenCADStudio { } } + // Single-line text height rows depend on both the + // justification and the active annotation scale. Aligned + // text derives its paper height from the two endpoints, + // while annotative text exposes that paper height and a + // separate calculated model height. + if let acadrust::EntityType::Text(text) = entity { + let aligned = matches!( + text.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + ); + let annotative = crate::scene::annotative::is_annotative(doc, entity); + let model_factor = if annotative { + annotation_scale_handle + .and_then(|handle| match doc.objects.get(&handle) { + Some(acadrust::objects::ObjectType::Scale(scale)) => Some( + scale.inverse_factor() + / self.tabs[i].scene.annotation_scale_unit_factor(), + ), + _ => None, + }) + .unwrap_or(self.tabs[i].scene.annotation_scale as f64) + } else { + 1.0 + }; + let paper_height = if aligned { + crate::entities::text::text_run_placement_at_scale( + text, + doc, + model_factor as f32, + ) + .height as f64 + } else { + text.height + }; + for section in sections.iter_mut() { + if let Some(row) = + section.props.iter_mut().find(|row| row.field == "height") + { + if annotative { + row.label = t!("Paper text height").into_owned(); + } + if aligned { + row.value = crate::scene::model::object::PropValue::ReadOnly( + crate::entities::common::format_length(paper_height), + ); + } + } + } + if annotative { + insert_row_after( + &mut sections, + "height", + crate::entities::common::ro_prop( + t!("Model text height").as_ref(), + "model_text_height", + crate::entities::common::format_length( + paper_height * model_factor, + ), + ), + ); + } + } + if !group_names.is_empty() { let label = group_names.join(", "); if let Some(general) = sections.first_mut() { diff --git a/src/app/text_inline.rs b/src/app/text_inline.rs index a66754cf..e505d908 100644 --- a/src/app/text_inline.rs +++ b/src/app/text_inline.rs @@ -95,6 +95,9 @@ pub struct TextInlineState { pub editing: Option, /// Which entity slot this session writes to on commit. pub field: TextEntityField, + /// Fully prepared entity supplied by the interactive TEXT command. Editing + /// existing text and legacy direct-open paths leave this empty. + pub creation: Option, /// Canvas-space anchor where the field is drawn (the insertion-point click). pub screen_anchor: iced::Point, } @@ -173,10 +176,10 @@ impl super::OpenCADStudio { }; if field.is_rich() { - self.open_mtext_editor(pos, Some(target), &value, height); + self.open_mtext_editor(pos, Some(target), &value, height, None); self.unfocus_widgets() } else { - self.open_text_inline(pos, Some(target), &value, height, field); + self.open_text_inline(pos, Some(target), &value, height, field, None); iced::widget::operation::focus(iced::widget::Id::new(super::view::TEXT_INLINE_ID)) } } @@ -190,6 +193,7 @@ impl super::OpenCADStudio { initial: &str, height: f64, field: TextEntityField, + creation: Option, ) { if handle.is_some_and(|h| self.tabs[self.active_tab].scene.is_layer_locked(h)) { return; @@ -200,6 +204,7 @@ impl super::OpenCADStudio { height: if height > 0.0 { height } else { 0.25 }, editing: handle, field, + creation, screen_anchor: iced::Point::new(60.0, 90.0), }; if let Some(p) = self.tabs[self.active_tab].scene.selection.borrow().last_move_pos { @@ -236,12 +241,18 @@ impl super::OpenCADStudio { } else { crate::command::WorkingPlane::default() }; - let position = plane.to_local(ed.pos); - let mut t = Text::with_value( - &ed.value, - Vector3::new(position.x, position.y, position.z), - ) - .with_height(ed.height); + let command_creation = ed.creation.is_some(); + let mut t = if let Some(mut prepared) = ed.creation { + prepared.value = ed.value.clone(); + prepared + } else { + let position = plane.to_local(ed.pos); + Text::with_value( + &ed.value, + Vector3::new(position.x, position.y, position.z), + ) + .with_height(ed.height) + }; // New text inherits the document's current text style (STYLE), not // the entity default. See #92. let cur_style = self.tabs[i] @@ -251,24 +262,48 @@ impl super::OpenCADStudio { .current_text_style_name .clone(); if !cur_style.is_empty() { - t.style = cur_style; - } - let annotative = crate::scene::annotative::text_style_is_annotative( - &self.tabs[i].scene.document, - &t.style, - ); - self.push_undo_snapshot(i, "TEXT"); - let handle = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); - if annotative { - let scale = self.tabs[i].scene.current_annotation_scale_handle(); - if let (Some(handle), Some(scale)) = (handle, scale) { - crate::scene::annotative::create_annotation_context( - &mut self.tabs[i].scene.document, - handle, - scale, - ); + if t.style.trim().is_empty() { + t.style = cur_style; } } + if command_creation { + let annotation_multiplier = if crate::scene::annotative::text_style_is_annotative( + &self.tabs[i].scene.document, + &t.style, + ) { + self.tabs[i].scene.creation_annotation_multiplier() + } else { + 1.0 + }; + let display_height = crate::entities::text::text_run_placement_at_scale( + &t, + &self.tabs[i].scene.document, + annotation_multiplier as f32, + ) + .height as f64 + * annotation_multiplier; + if let Some(command) = self.tabs[i].suspended_cmd.as_mut() { + command.on_editor_display_height(display_height); + } + } + self.push_undo_snapshot(i, "TEXT"); + self.tabs[i].scene.document.header.current_text_style_name = t.style.clone(); + let variable_height = self.tabs[i] + .scene + .document + .text_styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(&t.style)) + .is_none_or(|style| style.height <= 1.0e-9); + if variable_height + && !matches!( + t.horizontal_alignment, + acadrust::entities::TextHorizontalAlignment::Aligned + ) + { + self.tabs[i].scene.document.header.text_height = t.height; + } + let _ = self.commit_entity_handle(plane.place_entity(EntityType::Text(t))); self.tabs[i].dirty = true; } self.refresh_properties(); diff --git a/src/app/tolerance_dialog.rs b/src/app/tolerance_dialog.rs index 56e9b723..f5ea73d2 100644 --- a/src/app/tolerance_dialog.rs +++ b/src/app/tolerance_dialog.rs @@ -71,8 +71,28 @@ impl super::OpenCADStudio { return false; } let i = self.active_tab; - let mut command = - crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text(state.to_text()); + let text = state.to_text(); + let mut preview_entity = EntityType::Tolerance( + acadrust::entities::Tolerance::with_text( + acadrust::types::Vector3::ZERO, + text.clone(), + ), + ); + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut preview_entity, + ); + let preview_strokes = match &preview_entity { + EntityType::Tolerance(tolerance) => crate::entities::tolerance::preview_strokes( + tolerance, + &self.tabs[i].scene.document, + ), + _ => Vec::new(), + }; + let mut command = crate::modules::annotate::tolerance_cmd::ToleranceCommand::with_text( + text, + preview_strokes, + ); use crate::command::CadCommand; let plane = if self.tabs[i].editing_model_space() { self.tabs[i].ucs_xform().working_plane() diff --git a/src/app/update/command.rs b/src/app/update/command.rs index c097983b..fc21b8e7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1162,6 +1162,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if matches!( item.action, GripMenuAction::Stretch + | GripMenuAction::MoveWithText | GripMenuAction::MoveWithDimLine | GripMenuAction::MoveWithLeader | GripMenuAction::MoveIndependent @@ -1212,7 +1213,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { { (crate::entities::multileader::MOVE_ALL_GRIP, true) } else { - (popup.grip_id, if is_dimension { false } else { g.is_midpoint }) + ( + popup.grip_id, + matches!(item.action, GripMenuAction::MoveWithText) + || (!is_dimension && g.is_midpoint), + ) }; self.tabs[i].active_grip = Some(GripEdit::single( popup.handle, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1f77d4bf..bbf906ea 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -204,7 +204,14 @@ fn plot_scene_content( || !crate::scene::Scene::handle_from_wire_name(&wire.name) .and_then(|handle| scene.document.get_entity(handle)) .is_some_and(|entity| { - matches!(entity, acadrust::EntityType::Viewport(viewport) if crate::scene::Scene::is_content_viewport(viewport)) + matches!( + entity, + acadrust::EntityType::Viewport(viewport) + if !crate::scene::Scene::is_sheet_viewport( + &scene.document, + viewport, + ) + ) })) }); model_wires.retain(|wire| wire.plot_visible); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 7266953b..4c276d92 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -235,6 +235,25 @@ impl OpenCADStudio { } } + /// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection + /// set actually changed since the last broadcast. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn notify_plugins_selection_changed(&mut self) { + if self.active_tab >= self.tabs.len() { + return; + } + let i = self.active_tab; + let tab_id = self.tabs[i].id; + let fingerprint = self.tabs[i].scene.selection_fingerprint(); + let key = (tab_id, fingerprint); + if self.last_plugin_selection == Some(key) { + return; + } + self.last_plugin_selection = Some(key); + let handles = self.tabs[i].scene.selected_handles_in_order(); + crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles); + } + pub fn update(&mut self, msg: Message) -> Task { let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); @@ -279,6 +298,11 @@ impl OpenCADStudio { // The block panel watches the drawing's block list and rebuilds its // thumbnails whenever the names change (BLOCK define, file open, …). self.refresh_block_palette_if_stale(); + // Let V4 plugins observe selection changes that happened while handling + // this message (picking, window select, QSELECT, SELECTALL, grip edits, + // and plugin request draining). + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). @@ -3915,10 +3939,7 @@ impl OpenCADStudio { Task::none() } Message::MTextHeight(s) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.height = s; - } - self.rebuild_mtext_preview(); + self.mtext_apply_span_number("height", s); Task::none() } Message::MTextRectWidth(width) => { @@ -3950,26 +3971,128 @@ impl OpenCADStudio { Task::none() } Message::MTextOblique(s) => { - if let Some(ed) = self.mtext_editor.as_mut() { - ed.oblique = s; - } - self.rebuild_mtext_preview(); + self.mtext_apply_span_number("oblique", s); Task::none() } Message::MTextWidth(s) => { + self.mtext_apply_span_number("width", s); + Task::none() + } + Message::MTextCharSpace(s) => { + self.mtext_apply_span_number("tracking", s); + Task::none() + } + Message::MTextUndo => { + self.mtext_undo(); + Task::none() + } + Message::MTextRedo => { + self.mtext_redo(); + Task::none() + } + Message::MTextStack => { + self.mtext_stack_selection(); + Task::none() + } + Message::MTextClearFormatting => { + self.mtext_clear_formatting(); + Task::none() + } + Message::MTextInsert(value) => { + self.mtext_type(&value); + Task::none() + } + Message::MTextAnnotative(value) => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.width = s; + ed.annotative = value; } self.rebuild_mtext_preview(); Task::none() } - Message::MTextCharSpace(s) => { + Message::MTextColumnMode(mode) => { if let Some(ed) = self.mtext_editor.as_mut() { - ed.char_space = s; + match mode.as_str() { + "Static" => { + ed.column_type = 1; + ed.column_auto_height = false; + } + "Dynamic auto" => { + ed.column_type = 2; + ed.column_auto_height = true; + } + "Dynamic manual" => { + ed.column_type = 2; + ed.column_auto_height = false; + } + _ => ed.column_type = 0, + } } self.rebuild_mtext_preview(); Task::none() } + Message::MTextColumnCount(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_count = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnWidth(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_width = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnGutter(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_gutter = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnHeight(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.rect_height = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextColumnFlowReversed(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.column_flow_reversed = value; + } + self.rebuild_mtext_preview(); + Task::none() + } + Message::MTextParagraphNumber(field, value) => { + self.mtext_apply_paragraph_number(field, value); + Task::none() + } + Message::MTextFindText(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.find_text = value; + } + Task::none() + } + Message::MTextReplaceText(value) => { + if let Some(ed) = self.mtext_editor.as_mut() { + ed.replace_text = value; + } + Task::none() + } + Message::MTextFindNext => { + self.mtext_find_next(); + Task::none() + } + Message::MTextReplaceNext => { + self.mtext_replace_next(); + Task::none() + } + Message::MTextReplaceAll => { + self.mtext_replace_all(); + Task::none() + } Message::MTextJustify(ap) => { if let Some(ed) = self.mtext_editor.as_mut() { ed.attachment = ap; diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index a527895b..34eddd24 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -4620,7 +4620,10 @@ impl OpenCADStudio { if let Some(AcadEntityType::Viewport(vp)) = self.tabs[i].scene.document.get_entity(h) { - if Scene::is_content_viewport(vp) { + if !Scene::is_sheet_viewport( + &self.tabs[i].scene.document, + vp, + ) { Some(h) } else { None diff --git a/src/app/view/overlay.rs b/src/app/view/overlay.rs index b5782f81..c1c4dac3 100644 --- a/src/app/view/overlay.rs +++ b/src/app/view/overlay.rs @@ -6,7 +6,8 @@ use iced::advanced::renderer; use iced::advanced::widget; use iced::advanced::{Layout, Shell, Widget}; use iced::widget::{ - button, column, container, mouse_area, row, scrollable, stack, text, text_input, Space, + button, checkbox, column, container, mouse_area, row, scrollable, stack, text, text_input, + Space, }; use iced::{Background, Border, Color, Element, Event, Fill, Length, Rectangle, Size, Theme, Vector}; use crate::t; @@ -374,7 +375,7 @@ pub(super) fn mtext_editor_overlay<'a>( iced::widget::Space::new().width(Fill).height(Fill), t!("Text Editor"), content, - Message::MTextCancel, + Message::MTextOk, modal_offset, crate::ui::modal::ModalOptions::STANDARD, ) @@ -450,6 +451,9 @@ fn mtext_editor_content<'a>( .on_select(Message::MTextStyle) .text_size(11) .width(iced::Length::Fixed(96.0)); + let annotative = checkbox(ed.annotative) + .on_toggle(Message::MTextAnnotative) + .size(14); let font_sel = if ed.font.trim().is_empty() { "[Style default]".to_string() } else { @@ -487,6 +491,9 @@ fn mtext_editor_content<'a>( let row1 = row![ style_pl, + row![annotative, text(t!("Annotative")).size(11)] + .spacing(3) + .align_y(iced::Alignment::Center), font_pl, small_input("2.5", &ed.height, Message::MTextHeight, 64.0), iced::widget::Space::new().width(6), @@ -535,6 +542,14 @@ fn mtext_editor_content<'a>( .text_size(11) .width(iced::Length::Fixed(112.0)); let row2 = row![ + button(lbl("↶")) + .on_press(Message::MTextUndo) + .padding(3) + .style(btn_style), + button(lbl("↷")) + .on_press(Message::MTextRedo) + .padding(3) + .style(btn_style), lbl("O"), small_input("0", &ed.oblique, Message::MTextOblique, 48.0), lbl("W"), @@ -573,6 +588,144 @@ fn mtext_editor_content<'a>( .on_press(Message::MTextLineSpacing(2.0)) .padding(3) .style(btn_style), + button(lbl("Stack")) + .on_press(Message::MTextStack) + .padding(3) + .style(btn_style), + button(lbl("Clear")) + .on_press(Message::MTextClearFormatting) + .padding(3) + .style(btn_style), + button(lbl("°")) + .on_press(Message::MTextInsert("°".to_string())) + .padding(3) + .style(btn_style), + button(lbl("±")) + .on_press(Message::MTextInsert("±".to_string())) + .padding(3) + .style(btn_style), + button(lbl("⌀")) + .on_press(Message::MTextInsert("⌀".to_string())) + .padding(3) + .style(btn_style), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + + let column_mode = match (ed.column_type, ed.column_auto_height) { + (1, _) => "Static", + (2, true) => "Dynamic auto", + (2, false) => "Dynamic manual", + _ => "No columns", + } + .to_string(); + let column_picker = iced::widget::pick_list( + Some(column_mode), + ["No columns", "Static", "Dynamic auto", "Dynamic manual"] + .into_iter() + .map(str::to_string) + .collect::>(), + |value| value.to_string(), + ) + .on_select(Message::MTextColumnMode) + .text_size(11) + .width(iced::Length::Fixed(120.0)); + let reverse = checkbox(ed.column_flow_reversed) + .on_toggle(Message::MTextColumnFlowReversed) + .size(14); + let row3 = row![ + lbl("Columns"), + column_picker, + lbl("Count"), + small_input("2", &ed.column_count, Message::MTextColumnCount, 42.0), + lbl("Height"), + text_input("0", &ed.rect_height) + .on_input(Message::MTextColumnHeight) + .width(iced::Length::Fixed(58.0)) + .padding(3) + .size(12), + lbl("Width"), + small_input("0", &ed.column_width, Message::MTextColumnWidth, 58.0), + lbl("Gutter"), + small_input("0", &ed.column_gutter, Message::MTextColumnGutter, 58.0), + reverse, + lbl("Reverse"), + iced::widget::Space::new().width(8), + lbl("First"), + text_input("0", &ed.paragraph_first_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::FirstIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Left"), + text_input("0", &ed.paragraph_left_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::LeftIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Right"), + text_input("0", &ed.paragraph_right_indent) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::RightIndent, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("Before"), + text_input("0", &ed.paragraph_space_before) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceBefore, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + lbl("After"), + text_input("0", &ed.paragraph_space_after) + .on_input(|value| Message::MTextParagraphNumber( + super::super::mtext_editor::ParaNumber::SpaceAfter, + value, + )) + .width(iced::Length::Fixed(48.0)) + .padding(3) + .size(12), + ] + .spacing(4) + .align_y(iced::Alignment::Center) + .width(width); + let row4 = row![ + lbl("Find"), + text_input("Find", &ed.find_text) + .on_input(Message::MTextFindText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Next")) + .on_press(Message::MTextFindNext) + .padding(3) + .style(btn_style), + lbl("Replace"), + text_input("Replace", &ed.replace_text) + .on_input(Message::MTextReplaceText) + .width(iced::Length::Fixed(140.0)) + .padding(3) + .size(12), + button(lbl("Replace")) + .on_press(Message::MTextReplaceNext) + .padding(3) + .style(btn_style), + button(lbl("Replace All")) + .on_press(Message::MTextReplaceAll) + .padding(3) + .style(btn_style), ] .spacing(4) .align_y(iced::Alignment::Center) @@ -710,13 +863,13 @@ fn mtext_editor_content<'a>( .into() }; - // ── Top action bar: Apply on the right, exactly like the style managers' - // toolbar strip. Closing (the modal ✕) without applying discards the - // buffer, so there is no separate Cancel. + // ── Top action bar: Apply keeps editing; Close saves and exits. Escape + // remains the explicit discard path. let action_bar = container( row![ iced::widget::Space::new().width(width), crate::ui::style::style_manager::tb_button(t!("Apply"), Message::MTextApply, true), + crate::ui::style::style_manager::tb_button(t!("Close Text Editor"), Message::MTextOk, true), ] .align_y(iced::Alignment::Center), ) @@ -730,7 +883,7 @@ fn mtext_editor_content<'a>( .padding([5, 8]); container( - column![action_bar, row1, row2, width_slider, body] + column![action_bar, row1, row2, row3, row4, width_slider, body] .spacing(6) .width(width) .height(height), diff --git a/src/command.rs b/src/command.rs index 46cad343..d4a2312b 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1427,6 +1427,11 @@ pub enum CmdResult { handle: Option, initial: String, height: f64, + /// Optional entity defaults collected by the interactive MTEXT + /// command (boundary, rotation, attachment, spacing and columns). + /// Existing-entity edits leave this as `None` and load the document + /// entity instead. + template: Option>, }, /// Collect rich text without creating an MText entity. SuspendForMTextInput { @@ -1443,6 +1448,14 @@ pub enum CmdResult { initial: String, height: f64, }, + /// Suspend the active TEXT command while the in-place editor collects one + /// independent line. The prepared entity carries the chosen style, + /// justification, rotation and two-point geometry. When the editor closes, + /// the command resumes so another line can be placed directly below it. + SuspendForTextInput { + pos: DVec3, + entity: acadrust::entities::Text, + }, /// Apply new pattern/scale/angle to an existing hatch entity. HatcheditApply { handle: Handle, @@ -1861,6 +1874,8 @@ pub trait CadCommand: Send { /// Resume the command with collected rich text. fn on_editor_text(&mut self, _value: String) {} + fn on_editor_display_height(&mut self, _height: f64) {} + /// Called when the user clicks and `needs_entity_pick()` is true. /// `handle` is the nearest wire's entity handle (Handle::NULL if nothing found). fn on_entity_pick(&mut self, _handle: Handle, _pt: DVec3) -> CmdResult { diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index dd5cc539..626a2db4 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -200,6 +200,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument oblique_angle, is_backward: width_factor < 0.0, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { // Not an MTEXT: text in a fixed box, never columnar. @@ -213,6 +214,8 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -304,6 +307,7 @@ fn build_attr_render(input: AttrTextInputs<'_>, document: &acadrust::CadDocument origin, color: None, fill_tris, + plane: None, // Carry a GlyphRun so the attribute renders as SDF glyph quads // (same as TEXT); the strokes above are the historical fallback and // are suppressed by the per-group SDF path. diff --git a/src/entities/mtext.rs b/src/entities/mtext.rs index 5c6a46eb..657dc978 100644 --- a/src/entities/mtext.rs +++ b/src/entities/mtext.rs @@ -5,7 +5,8 @@ use crate::entities::common::{ edit_angle_prop as edit_angle, edit_prop as edit, num_prop as num_row, ro_prop as ro, square_grip, triangle_grip, }; use crate::entities::text_support::{ - layout_mtext, resolve_text_style, GlyphBox, MTextColumns, MTextRenderOpts, MTextVAnchor, + clamp_mtext_column_count, layout_mtext, resolve_text_style, GlyphBox, MTextColumns, + MTextRenderOpts, MTextVAnchor, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; @@ -24,10 +25,19 @@ fn columns_of(t: &MText) -> MTextColumns { if c.column_type == 0 { return MTextColumns::default(); } + let count = clamp_mtext_column_count(c.column_count) as usize; MTextColumns { - count: c.column_count.max(0) as usize, + count, width: c.width as f32, gutter: c.gutter as f32, + flow_reversed: c.flow_reversed, + auto_height: c.auto_height, + heights: c + .heights + .iter() + .take(count) + .map(|height| *height as f32) + .collect(), } } @@ -120,7 +130,14 @@ pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec attach_h_anchor, v_anchor, line_spacing_factor: t.line_spacing_factor as f32, - vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + exact_line_spacing: matches!( + t.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) + || matches!(t.drawing_direction, DrawingDirection::ByStyle) + && resolved_style.is_vertical, want_glyph_boxes: true, columns: columns_of(t), }); @@ -169,7 +186,14 @@ fn to_render(t: &MText, document: &acadrust::CadDocument) -> RenderEntity { attach_h_anchor, v_anchor, line_spacing_factor: t.line_spacing_factor as f32, - vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom), + exact_line_spacing: matches!( + t.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: t.rectangle_height.unwrap_or(0.0) as f32, + vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom) + || matches!(t.drawing_direction, DrawingDirection::ByStyle) + && resolved_style.is_vertical, want_glyph_boxes: false, columns: columns_of(t), }); @@ -231,8 +255,43 @@ fn grips(t: &MText) -> Vec { t.insertion_point.z, ); let (dir, k) = width_grip_axis(t); - let width_grip = p + dir * (k * t.rectangle_width.max(0.0)); - vec![square_grip(0, p), triangle_grip(1, width_grip)] + let column_count = clamp_mtext_column_count(t.column_data.column_count); + let columns_active = + t.column_data.column_type != 0 && column_count > 1 && t.column_data.width > 0.0; + let block_width = if columns_active { + t.column_data.width * column_count as f64 + + t.column_data.gutter * (column_count - 1) as f64 + } else { + t.rectangle_width.max(0.0) + }; + let width_grip = p + dir * (k * block_width); + let mut result = vec![square_grip(0, p), triangle_grip(1, width_grip)]; + + let height = t + .rectangle_height + .or_else(|| t.column_data.heights.first().copied()) + .unwrap_or(0.0); + if height > 0.0 && !(columns_active && t.column_data.auto_height) { + let (sin, cos) = t.rotation.sin_cos(); + let down = glam::DVec3::new(sin, -cos, 0.0); + let (_, vertical) = attach_anchors(t); + let factor = match vertical { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + }; + result.push(triangle_grip(2, p + down * (factor * height))); + } + if columns_active { + result.push(triangle_grip(3, p + dir * (k * t.column_data.width))); + result.push(triangle_grip( + 4, + p + dir * (k * (t.column_data.width + t.column_data.gutter)), + )); + } + result } fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str { @@ -248,10 +307,9 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { // by the line-spacing factor. let line_space_distance = t.height * 1.666_666_666_666_667 * t.line_spacing_factor; let text_frame_on = (t.background_fill_flags & 0x10) != 0; - // Defined width is only live without columns; defined height is live for - // static columns or manual-height dynamic columns, grayed otherwise. + // Defined width is reported by Properties but is not edited there. Defined + // height remains live for static columns or manual-height dynamic columns. let col_type = t.column_data.column_type; - let width_editable = col_type == 0; let height_editable = col_type == 1 || (col_type == 2 && !t.column_data.auto_height); vec![ PropSection { @@ -353,7 +411,7 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { .collect(), }, }, - num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, width_editable), + num_row(t!("Defined width").as_ref(), "rect_w", t.rectangle_width, false), num_row( t!("Defined height").as_ref(), "rect_h", @@ -371,20 +429,6 @@ fn properties(t: &MText, text_style_names: &[String]) -> Vec { .collect(), }, }, - // Count / width / gutter are live only when columns are on. - num_row( - t!("Column count").as_ref(), - "col_count", - t.column_data.column_count as f64, - col_type != 0, - ), - num_row(t!("Column width").as_ref(), "col_width", t.column_data.width, col_type != 0), - num_row( - t!("Column gutter").as_ref(), - "col_gutter", - t.column_data.gutter, - col_type != 0, - ), Property { label: t!("Text frame").into_owned(), field: "text_frame", @@ -502,21 +546,20 @@ fn apply_geom_prop(t: &mut MText, field: &str, value: &str) { "ins_y" => t.insertion_point.y = v, "ins_z" => t.insertion_point.z = v, "height" if v > 0.0 => t.height = v, - "rect_w" if v > 0.0 => t.rectangle_width = v, - "rect_h" if v > 0.0 => t.rectangle_height = Some(v), + "rect_h" if v >= 0.0 => t.rectangle_height = (v > 0.0).then_some(v), "rotation" => t.rotation = v.to_radians(), - "line_spacing" if v > 0.0 => t.line_spacing_factor = v, + "line_spacing" if (0.25..=4.0).contains(&v) => t.line_spacing_factor = v, // Editing the absolute distance back-solves the line-spacing factor so // the two stay consistent (distance = height × 5/3 × factor). "line_space_distance" if v > 0.0 => { let denom = t.height * 1.666_666_666_666_667; if denom > 0.0 { - t.line_spacing_factor = v / denom; + let factor = v / denom; + if (0.25..=4.0).contains(&factor) { + t.line_spacing_factor = factor; + } } } - "col_count" if v >= 1.0 => t.column_data.column_count = v.round() as i32, - "col_width" if v > 0.0 => t.column_data.width = v, - "col_gutter" if v >= 0.0 => t.column_data.gutter = v, _ => {} } } @@ -541,7 +584,60 @@ fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) { let dx = p.x as f64 - t.insertion_point.x; let dy = p.y as f64 - t.insertion_point.y; let projected = dx * dir.x + dy * dir.y; - t.rectangle_width = (projected / k).max(0.01); + let width = (projected / k).max(0.0); + let column_count = clamp_mtext_column_count(t.column_data.column_count); + if t.column_data.column_type != 0 && column_count > 1 { + t.column_data.column_count = column_count; + let gaps = (column_count - 1) as f64; + t.column_data.width = + ((width - t.column_data.gutter * gaps) / column_count as f64) + .max(0.01); + } else { + t.rectangle_width = width; + } + } + (2, GripApply::Absolute(p)) => { + let (sin, cos) = t.rotation.sin_cos(); + let down = glam::DVec3::new(sin, -cos, 0.0); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + p.z as f64 - t.insertion_point.z, + ); + let (_, vertical) = attach_anchors(t); + let factor = match vertical { + MTextVAnchor::Top + | MTextVAnchor::MiddleOfTopLine + | MTextVAnchor::BottomOfTopLine => 1.0, + MTextVAnchor::Middle => 0.5, + MTextVAnchor::Bottom | MTextVAnchor::MiddleOfBottomLine => -1.0, + }; + let height = (delta.dot(down) / factor).max(0.01); + t.rectangle_height = Some(height); + if t.column_data.column_type != 0 && !t.column_data.auto_height { + let column_count = clamp_mtext_column_count(t.column_data.column_count); + t.column_data.column_count = column_count; + t.column_data.heights = vec![height; column_count as usize]; + } + } + (3, GripApply::Absolute(p)) => { + let (dir, k) = width_grip_axis(t); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + 0.0, + ); + t.column_data.width = (delta.dot(dir) / k).max(0.01); + } + (4, GripApply::Absolute(p)) => { + let (dir, k) = width_grip_axis(t); + let delta = glam::DVec3::new( + p.x as f64 - t.insertion_point.x, + p.y as f64 - t.insertion_point.y, + 0.0, + ); + t.column_data.gutter = + (delta.dot(dir) / k - t.column_data.width).max(0.0); } _ => {} } diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index ed985a38..4084a780 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -195,6 +195,11 @@ fn to_render(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option ([f64; 2], [f64; 3]) { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; let layout = layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -424,6 +430,11 @@ fn text_box_geom(ml: &MultiLeader) -> ([f64; 2], [f64; 3]) { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: ctx.line_spacing_factor as f32, + exact_line_spacing: matches!( + ctx.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: 0.0, vertical_text: vertical, want_glyph_boxes: false, }); @@ -2039,6 +2050,11 @@ impl MultiLeaderTess for MultiLeader { attach_h_anchor: h_anchor, v_anchor, line_spacing_factor: ctx.line_spacing_factor as f32, + exact_line_spacing: matches!( + ctx.line_spacing_style, + acadrust::entities::LineSpacingStyle::Exactly + ), + rectangle_height: 0.0, // Vertical flow (top-to-bottom) is stored per-context. vertical_text: matches!( ctx.text_flow_direction, diff --git a/src/entities/raster_image.rs b/src/entities/raster_image.rs index 604d48d3..51937cf5 100644 --- a/src/entities/raster_image.rs +++ b/src/entities/raster_image.rs @@ -180,6 +180,7 @@ impl RenderConvertible for RasterImage { origin: ins, color: None, fill_tris: vec![], + plane: None, run: None, }]; @@ -218,6 +219,7 @@ impl RenderConvertible for RasterImage { origin, color: None, fill_tris, + plane: None, run: Some(GlyphRun { text: path.to_string(), font, diff --git a/src/entities/table.rs b/src/entities/table.rs index 6660c8c6..0a75045d 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -1187,6 +1187,7 @@ impl RenderConvertible for Table { oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } }; @@ -1287,6 +1288,8 @@ impl RenderConvertible for Table { attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -1453,6 +1456,7 @@ pub fn tessellate_table( oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } }; @@ -1920,6 +1924,8 @@ pub fn tessellate_table( attach_h_anchor, v_anchor, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); diff --git a/src/entities/text.rs b/src/entities/text.rs index 18a96fda..684b3612 100644 --- a/src/entities/text.rs +++ b/src/entities/text.rs @@ -43,7 +43,24 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { (HA::Left, VA::Baseline) ); if needs_alignment_point { - if t.alignment_point.is_none() { + if matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) { + let point = t.alignment_point.unwrap_or(t.insertion_point); + let dx = point.x - t.insertion_point.x; + let dy = point.y - t.insertion_point.y; + if dx.hypot(dy) <= 1.0e-9 { + let span = t.height.max(1.0e-6) + * t.width_factor.abs().max(0.01) + * t.value.chars().count().max(1) as f64 + * 0.6; + t.alignment_point = Some(acadrust::types::Vector3::new( + t.insertion_point.x + t.rotation.cos() * span, + t.insertion_point.y + t.rotation.sin() * span, + t.insertion_point.z, + )); + } else { + t.alignment_point = Some(point); + } + } else if t.alignment_point.is_none() { t.alignment_point = Some(t.insertion_point); } } else { @@ -51,6 +68,21 @@ pub(crate) fn sync_text_alignment_point(t: &mut Text) { } } +fn two_point_span(t: &Text) -> Option<(f64, f64)> { + if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) { + return None; + } + let point = t.alignment_point?; + let dx = point.x - t.insertion_point.x; + let dy = point.y - t.insertion_point.y; + let distance = dx.hypot(dy); + (distance > 1.0e-9).then(|| (distance, dy.atan2(dx))) +} + +fn displayed_rotation(t: &Text) -> f64 { + two_point_span(t).map_or(t.rotation, |(_, angle)| angle) +} + /// Resolved placement of a TEXT run: the baseline-anchored run origin (WCS xy) /// plus every parameter needed to lay the glyphs out. Shared by `to_render` (the /// stroke path) and the SDF-quad text collector so both place text identically. @@ -98,8 +130,12 @@ pub(crate) fn acad_text_encode(value: &str) -> String { out } -fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity { - let p = text_run_placement(t, document); +pub(crate) fn to_render_at_scale( + t: &Text, + document: &acadrust::CadDocument, + annotation_scale: f32, +) -> RenderEntity { + let p = text_run_placement_at_scale(t, document, annotation_scale); let snap_pt = glam::DVec3::new(p.wcs_insertion[0], p.wcs_insertion[1], p.wcs_insertion[2]); // Parse `%%` codes via acadrust, re-encoded for the stroke tessellator. let value = acad_text_encode(&p.value); @@ -120,6 +156,7 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity { origin: p.origin, color: None, fill_tris, + plane: None, run: Some(GlyphRun { text: value, font: p.font.clone(), @@ -140,7 +177,16 @@ fn to_render(t: &Text, document: &acadrust::CadDocument) -> RenderEntity { /// Compute a TEXT entity's run placement (origin + layout params). Extracted /// from `to_render` verbatim so the stroke and SDF-quad paths agree exactly. -pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPlacement { +pub fn text_run_placement_at_scale( + t: &Text, + document: &acadrust::CadDocument, + annotation_scale: f32, +) -> TextPlacement { + let annotation_scale = if annotation_scale.is_finite() && annotation_scale > 1.0e-9 { + annotation_scale + } else { + 1.0 + }; let normal = (t.normal.x, t.normal.y, t.normal.z); let (wsx, wsy, wsz) = crate::scene::view::transform::ocs_point_to_wcs( ( @@ -152,10 +198,9 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla ); let resolved_style = resolve_text_style(&t.style, document); let font_name = resolved_style.font_name; - // AutoCAD text geometry rule: the entity stores the FINAL width factor / - // oblique angle, copied from the style at creation and persisting through - // style edits. Use it as-is. Only fall back to the style when the entity - // value is missing (the parser reports 0.0 for default-omitted fields). + // The entity stores the final width factor and oblique angle copied from + // its style at creation. Only fall back to the style when an omitted field + // was read as zero. let base_wf = if t.width_factor.abs() > 1e-9 { (t.width_factor as f32).clamp(0.01, 100.0) } else { @@ -168,17 +213,44 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla // mirror, and XOR keeps a double mirror an involution. let eff_backward = resolved_style.is_backward ^ (t.generation_flags & 0x2 != 0); let eff_upside = resolved_style.is_upside_down ^ (t.generation_flags & 0x4 != 0); - let width_factor = if eff_backward { -base_wf } else { base_wf }; - let rotation = if eff_upside { - t.rotation as f32 + std::f32::consts::PI - } else { - t.rotation as f32 - }; + let mut width_factor = if eff_backward { -base_wf } else { base_wf }; let oblique_angle = if t.oblique_angle.abs() > 1e-9 { t.oblique_angle as f32 } else { resolved_style.oblique_angle }; + let value_for_bounds = resolve_dxf_special_chars(&t.value); + let mut height = t.height.max(1.0e-9) as f32; + let mut base_rotation = t.rotation as f32; + + // Aligned and Fit are true two-point modes. Both derive their baseline + // direction from the endpoints. Aligned scales height uniformly; Fit keeps + // the height and changes only the horizontal factor. + if let Some((span, angle)) = two_point_span(t) { + base_rotation = angle as f32; + if let Some(base_bounds) = text_local_bounds( + &font_name, + &value_for_bounds, + height, + width_factor, + oblique_angle, + ) { + if base_bounds.advance > 1.0e-6 { + let scale = + (span as f32 / annotation_scale / base_bounds.advance).max(1.0e-6); + if matches!(t.horizontal_alignment, HA::Aligned) { + height *= scale; + } else { + width_factor *= scale; + } + } + } + } + let rotation = if eff_upside { + base_rotation + std::f32::consts::PI + } else { + base_rotation + }; // Anchor stays f64: large coordinates (UTM etc.) lose ~0.5 units of // precision when cast to f32, which snaps text baselines onto a coarse // grid and makes adjacent rows collide. Only the small local offsets @@ -188,17 +260,16 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla &t.vertical_alignment, &t.alignment_point, ) { - (HA::Aligned | HA::Middle | HA::Fit, _, Some(a)) => [a.x, a.y], + (HA::Aligned | HA::Fit, _, _) => [t.insertion_point.x, t.insertion_point.y], + (HA::Middle, _, Some(a)) => [a.x, a.y], (HA::Center | HA::Right, _, Some(a)) => [a.x, a.y], (_, VA::Bottom | VA::Middle | VA::Top, Some(a)) => [a.x, a.y], _ => [t.insertion_point.x, t.insertion_point.y], }; - // Strip %%u/%%o for bounds (they add no width); resolve %%d/%%c/%%p for correct advance. - let value_for_bounds = resolve_dxf_special_chars(&t.value); let bounds = text_local_bounds( &font_name, &value_for_bounds, - t.height as f32, + height, width_factor, oblique_angle, ); @@ -213,7 +284,8 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla let ax = match t.horizontal_alignment { HA::Left => 0.0, HA::Center | HA::Middle => b.advance * 0.5 * sign, - HA::Right | HA::Aligned | HA::Fit => b.advance * sign, + HA::Right => b.advance * sign, + HA::Aligned | HA::Fit => 0.0, }; // Vertical anchor uses the inked extent (cap / baseline geometry). let ay = match t.vertical_alignment { @@ -236,7 +308,7 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla ]; TextPlacement { origin, - height: t.height as f32, + height, rotation, width_factor, oblique_angle, @@ -247,12 +319,19 @@ pub fn text_run_placement(t: &Text, document: &acadrust::CadDocument) -> TextPla } fn grips(t: &Text) -> Vec { - let p = glam::DVec3::new( + let insertion = glam::DVec3::new( t.insertion_point.x, t.insertion_point.y, t.insertion_point.z, ); - vec![square_grip(0, p)] + let mut grips = vec![square_grip(0, insertion)]; + if let Some(point) = t.alignment_point { + grips.push(square_grip( + 1, + glam::DVec3::new(point.x, point.y, point.z), + )); + } + grips } fn properties(t: &Text, text_style_names: &[String]) -> Vec { @@ -263,15 +342,25 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { // both points are live. let is_plain_left = matches!(t.horizontal_alignment, HA::Left) && matches!(t.vertical_alignment, VA::Baseline); - let pos_editable = is_plain_left || matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); - let align_editable = !is_plain_left; - // The alignment point is meaningless (reset to the origin) for plain-Left text. + let is_aligned = matches!(t.horizontal_alignment, HA::Aligned); + let is_two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); + // The visible Properties palette exposes Text alignment as calculated + // coordinates and Position as the editable placement point. For aligned + // and fit text Position targets the first endpoint; for every other + // non-left mode it targets the alignment anchor that actually places the + // glyph run. + let position_uses_alignment = !is_plain_left && !is_two_point; let ap = t.alignment_point.unwrap_or(t.insertion_point); - let (ax, ay, az) = if align_editable { + let (ax, ay, az) = if !is_plain_left { (ap.x, ap.y, ap.z) } else { (0.0, 0.0, 0.0) }; + let position = if position_uses_alignment { + ap + } else { + t.insertion_point + }; vec![ PropSection { title: t!("Text").into_owned(), @@ -331,22 +420,31 @@ fn properties(t: &Text, text_style_names: &[String]) -> Vec { t!("Height").as_ref(), "height", t.height, - crate::entities::common::style_fixed_height(&t.style).is_none(), + !is_aligned + && crate::entities::common::style_fixed_height(&t.style).is_none(), ), - edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()), + if is_two_point { + ro( + t!("Rotation").as_ref(), + "rotation", + crate::entities::common::format_angle(displayed_rotation(t)), + ) + } else { + edit_angle(t!("Rotation").as_ref(), "rotation", t.rotation.to_degrees()) + }, edit(t!("Width factor").as_ref(), "width_factor", t.width_factor), edit_angle(t!("Obliquing").as_ref(), "oblique_angle", t.oblique_angle.to_degrees()), - num_row(t!("Text alignment X").as_ref(), "align_x", ax, align_editable), - num_row(t!("Text alignment Y").as_ref(), "align_y", ay, align_editable), - num_row(t!("Text alignment Z").as_ref(), "align_z", az, align_editable), + num_row(t!("Text alignment X").as_ref(), "align_x", ax, false), + num_row(t!("Text alignment Y").as_ref(), "align_y", ay, false), + num_row(t!("Text alignment Z").as_ref(), "align_z", az, false), ], }, PropSection { title: t!("Geometry").into_owned(), props: vec![ - num_row(t!("Position X").as_ref(), "ins_x", t.insertion_point.x, pos_editable), - num_row(t!("Position Y").as_ref(), "ins_y", t.insertion_point.y, pos_editable), - num_row(t!("Position Z").as_ref(), "ins_z", t.insertion_point.z, pos_editable), + num_row(t!("Position X").as_ref(), "ins_x", position.x, true), + num_row(t!("Position Y").as_ref(), "ins_y", position.y, true), + num_row(t!("Position Z").as_ref(), "ins_z", position.z, true), ], }, PropSection { @@ -439,37 +537,64 @@ fn apply_geom_prop(t: &mut Text, field: &str, value: &str) { return; }; match field { - "ins_x" => t.insertion_point.x = v, - "ins_y" => t.insertion_point.y = v, - "ins_z" => t.insertion_point.z = v, - "align_x" | "align_y" | "align_z" => { - let ins = t.insertion_point; - let ap = t.alignment_point.get_or_insert(ins); + "ins_x" | "ins_y" | "ins_z" => { + let plain_left = matches!(t.horizontal_alignment, HA::Left) + && matches!(t.vertical_alignment, VA::Baseline); + let two_point = matches!(t.horizontal_alignment, HA::Aligned | HA::Fit); + let target = if !plain_left && !two_point { + let insertion = t.insertion_point; + t.alignment_point.get_or_insert(insertion) + } else { + &mut t.insertion_point + }; match field { - "align_x" => ap.x = v, - "align_y" => ap.y = v, - _ => ap.z = v, + "ins_x" => target.x = v, + "ins_y" => target.y = v, + _ => target.z = v, } } - "height" if v > 0.0 => t.height = v, - "rotation" => t.rotation = v.to_radians(), + "align_x" | "align_y" | "align_z" => { + // Calculated display rows are intentionally not writable. + return; + } + "height" + if v > 0.0 && !matches!(t.horizontal_alignment, HA::Aligned) => + { + t.height = v + } + "rotation" if !matches!(t.horizontal_alignment, HA::Aligned | HA::Fit) => { + t.rotation = v.to_radians() + } "width_factor" if v > 0.0 => t.width_factor = v, - "oblique_angle" => t.oblique_angle = v.to_radians(), + "oblique_angle" if (-85.0..=85.0).contains(&v) => { + t.oblique_angle = v.to_radians() + } _ => {} } } -fn apply_grip(t: &mut Text, _grip_id: usize, apply: GripApply) { +fn apply_grip(t: &mut Text, grip_id: usize, apply: GripApply) { match apply { GripApply::Absolute(p) => { - t.insertion_point.x = p.x as f64; - t.insertion_point.y = p.y as f64; - t.insertion_point.z = p.z as f64; + let target = if grip_id == 1 { + let insertion = t.insertion_point; + t.alignment_point.get_or_insert(insertion) + } else { + &mut t.insertion_point + }; + target.x = p.x; + target.y = p.y; + target.z = p.z; } GripApply::Translate(d) => { - t.insertion_point.x += d.x as f64; - t.insertion_point.y += d.y as f64; - t.insertion_point.z += d.z as f64; + t.insertion_point.x += d.x; + t.insertion_point.y += d.y; + t.insertion_point.z += d.z; + if let Some(point) = t.alignment_point.as_mut() { + point.x += d.x; + point.y += d.y; + point.z += d.z; + } } } } @@ -495,7 +620,7 @@ fn apply_transform(t: &mut Text, tr: &EntityTransform) { impl RenderConvertible for Text { fn to_render(&self, document: &acadrust::CadDocument) -> Option { - Some(to_render(self, document)) + Some(to_render_at_scale(self, document, 1.0)) } } @@ -551,7 +676,9 @@ impl Grippable for Text { value: f64, ) { use crate::scene::model::object::GripMenuAction as A; - if matches!(action, A::RotateText) { + if matches!(action, A::RotateText) + && !matches!(self.horizontal_alignment, HA::Aligned | HA::Fit) + { self.rotation = value.to_radians(); } } diff --git a/src/entities/text_support.rs b/src/entities/text_support.rs index 0ddc32aa..2e4b83ed 100644 --- a/src/entities/text_support.rs +++ b/src/entities/text_support.rs @@ -11,6 +11,7 @@ pub struct ResolvedTextStyle { pub oblique_angle: f32, pub is_backward: bool, pub is_upside_down: bool, + pub is_vertical: bool, } pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedTextStyle { @@ -76,6 +77,7 @@ pub fn resolve_text_style(style_name: &str, document: &CadDocument) -> ResolvedT oblique_angle: style.map(|s| s.oblique_angle as f32).unwrap_or(0.0), is_backward: style.map(|s| s.is_backward()).unwrap_or(false), is_upside_down: style.map(|s| s.is_upside_down()).unwrap_or(false), + is_vertical: style.map(|s| s.is_vertical).unwrap_or(false), } } @@ -1012,6 +1014,11 @@ pub struct MTextRenderOpts<'a> { pub v_anchor: MTextVAnchor, /// DXF code 44 — multiplier on the default 5/3-em baseline gap. pub line_spacing_factor: f32, + /// `true` fixes every baseline advance to the entity spacing. `false` + /// treats it as a minimum and expands for taller inline runs. + pub exact_line_spacing: bool, + /// User-defined text-box/column height. Zero means content-driven. + pub rectangle_height: f32, /// `true` when the entity is laid out top-to-bottom (DXF code 71 = 2). pub vertical_text: bool, /// When true, `layout_mtext` also fills `MTextLayout::glyph_boxes` with @@ -1025,10 +1032,15 @@ pub struct MTextRenderOpts<'a> { /// An MTEXT's column layout, flattened from its `column_data`. /// -/// Content moves to the next column at a `\N` break. Filling a column and -/// spilling into the next on its own is not modelled: `heights` / `auto_height` -/// are not read, so a column runs as long as its content does. -#[derive(Clone, Copy, Debug, Default)] +/// Content moves to the next column at an explicit `\N` break or when the +/// configured manual/automatic column height is exhausted. +pub(crate) const MAX_MTEXT_COLUMNS: i32 = 256; + +pub(crate) fn clamp_mtext_column_count(count: i32) -> i32 { + count.clamp(1, MAX_MTEXT_COLUMNS) +} + +#[derive(Clone, Debug, Default)] pub struct MTextColumns { /// Column count. 0 or 1 both mean "no column layout". pub count: usize, @@ -1036,6 +1048,12 @@ pub struct MTextColumns { pub width: f32, /// Gap between two adjacent columns, world units. pub gutter: f32, + /// Whether logical column flow starts at the opposite side. + pub flow_reversed: bool, + /// Dynamic-column height is balanced from content when true. + pub auto_height: bool, + /// Optional manual height for each logical column. + pub heights: Vec, } impl MTextColumns { @@ -1046,7 +1064,17 @@ impl MTextColumns { /// Left edge of column `i`, relative to the text block's own left edge. pub fn offset_of(&self, i: usize) -> f32 { - i as f32 * (self.width + self.gutter) + let physical = if self.flow_reversed { + self.count.saturating_sub(1).saturating_sub(i) + } else { + i + }; + physical as f32 * (self.width + self.gutter) + } + + fn total_width(&self) -> f32 { + self.width * self.count as f32 + + self.gutter.max(0.0) * self.count.saturating_sub(1) as f32 } } @@ -1118,6 +1146,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { indent_right: f32, tab_stops: Vec, is_first_in_paragraph: bool, + starts_column: bool, /// Which column this line lives in; always 0 without a column layout. column: usize, /// Extra gap above (paragraph's first wrapped line only) / below (last @@ -1128,7 +1157,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { line_spacing: Option, } - let cols = opts.columns; + let cols = opts.columns.clone(); // A `\N` past the last column has nowhere to go — keep those lines in the // last one rather than laying them out beyond the block. let last_col = cols.count.saturating_sub(1); @@ -1255,6 +1284,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { indent_right: para.indent_right, tab_stops: para.tab_stops.clone(), is_first_in_paragraph: idx == 0, + starts_column: idx == 0 && para.starts_column, column, // The gap above rides the first wrapped line; the gap below the // last, so an interior wrap keeps normal single spacing. @@ -1277,6 +1307,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { indent_right: 0.0, tab_stops: Vec::new(), is_first_in_paragraph: true, + starts_column: false, column: 0, space_before: 0.0, space_after: 0.0, @@ -1366,15 +1397,86 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { }; // A paragraph's own `\psm#`/`\pse#` overrides the entity factor for // its lines; `Exact` fixes the baseline gap outright. + let entity_gap = entity_h * ls_factor * (5.0 / 3.0) * base_font.line_spacing(); match s.line_spacing { Some(ParaLineSpacing::Exact(e)) if e > 0.0 => e, Some(ParaLineSpacing::Multiple(m)) if m > 0.0 => { mh * m * (5.0 / 3.0) * base_font.line_spacing() } - _ => mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing(), + _ if opts.exact_line_spacing => entity_gap, + _ => (mh * ls_factor * (5.0 / 3.0) * base_font.line_spacing()) + .max(entity_gap), } }) .collect(); + + // Flow lines into the next column when the active column's configured + // height is exhausted. Explicit `\N` breaks remain authoritative. Dynamic + // auto-height balances the content; static/manual columns use their stored + // height (falling back to the entity rectangle height). + if cols.active() { + let mut pending_after = 0.0_f32; + let total_advance: f32 = sub_lines + .iter() + .enumerate() + .map(|(index, line)| { + if line.starts_column { + pending_after = 0.0; + } + let spacing = if line.is_first_in_paragraph { + line.space_before + pending_after + } else { + 0.0 + }; + pending_after = line.space_after; + per_line_h.get(index).copied().unwrap_or(entity_h) + spacing + }) + .sum(); + let balanced = (total_advance / cols.count.max(1) as f32).max(entity_h); + let mut current_column = 0usize; + let mut used = 0.0_f32; + let mut pending_after = 0.0_f32; + for (index, line) in sub_lines.iter_mut().enumerate() { + if line.starts_column { + current_column = (current_column + 1).min(cols.count - 1); + used = 0.0; + pending_after = 0.0; + } + let limit = if cols.auto_height { + balanced + } else { + cols.heights + .get(current_column) + .copied() + .filter(|height| *height > 0.0) + .unwrap_or(opts.rectangle_height) + }; + let line_advance = per_line_h.get(index).copied().unwrap_or(entity_h); + let spacing = if line.is_first_in_paragraph { + line.space_before + pending_after + } else { + 0.0 + }; + let mut advance = line_advance + spacing; + if limit > 0.0 + && used > 0.0 + && used + advance > limit + && current_column + 1 < cols.count + { + current_column += 1; + used = 0.0; + advance = line_advance + + if line.is_first_in_paragraph { + line.space_before + } else { + 0.0 + }; + } + line.column = current_column; + used += advance; + pending_after = line.space_after; + } + } // Per-line text height — the tallest Word on the line, an empty line // inheriting the previous line's height (same rule as `per_line_h`). Unlike // `line_max_h` it has no `entity_h` floor, so it reflects the ACTUAL text @@ -1442,7 +1544,12 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { MTextVAnchor::BottomOfTopLine => 0.0, }; let attach_h_anchor = opts.attach_h_anchor; - let box_left = -attach_h_anchor * rect_w; + let block_width = if cols.active() { + cols.total_width() + } else { + rect_w + }; + let box_left = -attach_h_anchor * block_width; let rot = opts.rotation; let (cos_r, sin_r) = (rot.cos(), rot.sin()); let ins_x = opts.insertion[0]; @@ -1734,6 +1841,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { origin, color, fill_tris, + plane: None, run: Some(GlyphRun { text: s, font: font_name.to_string(), @@ -1806,6 +1914,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { origin, color, fill_tris, + plane: None, run: Some(GlyphRun { text: text.clone(), font: font_name.to_string(), @@ -1840,6 +1949,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { origin, color, fill_tris: vec![], + plane: None, run: None, }); } @@ -1938,6 +2048,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { origin, color, fill_tris, + plane: None, run: Some(GlyphRun { text: text.to_string(), font: font_name.to_string(), @@ -1953,6 +2064,49 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { emit(denominator, valign_dy); emit(numerator, valign_dy + run_h * STACK_RAISE); + if opts.want_glyph_boxes { + let slots = numerator.chars().count() + + denominator.chars().count() + + usize::from(!denominator.is_empty()); + if slots > 0 { + let top = valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE); + for index in 0..slots { + let x0 = cursor_x + slot_w * index as f32 / slots as f32; + let x1 = cursor_x + slot_w * (index + 1) as f32 / slots as f32; + let corners = [ + to_world(line_base_x, line_base_y, x0, valign_dy), + to_world(line_base_x, line_base_y, x1, valign_dy), + to_world(line_base_x, line_base_y, x0, top), + to_world(line_base_x, line_base_y, x1, top), + ]; + let xmin = corners + .iter() + .map(|p| p.0) + .fold(f32::INFINITY, f32::min); + let xmax = corners + .iter() + .map(|p| p.0) + .fold(f32::NEG_INFINITY, f32::max); + let ymin = corners + .iter() + .map(|p| p.1) + .fold(f32::INFINITY, f32::min); + let ymax = corners + .iter() + .map(|p| p.1) + .fold(f32::NEG_INFINITY, f32::max); + glyph_boxes.push(GlyphBox { + vis, + xmin, + xmax, + ymin, + ymax, + }); + vis += 1; + } + } + } + if *bar && slot_w > 0.0 { // The rule is plain geometry, not a glyph, so it rides a // run-less group — those keep their strokes, while a @@ -1972,9 +2126,15 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { origin: [ox, oy], color, fill_tris: Vec::new(), + plane: None, run: None, }); } + local_bounds[0] = local_bounds[0].min(cursor_x); + local_bounds[1] = local_bounds[1].min(line_ly + valign_dy); + local_bounds[2] = local_bounds[2].max(cursor_x + slot_w); + local_bounds[3] = local_bounds[3] + .max(line_ly + valign_dy + run_h * (STACK_RAISE + STACK_HALF_SCALE)); cursor_x += slot_w; } AtomKind::Space => { @@ -2041,6 +2201,7 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout { origin, color, fill_tris: vec![], + plane: None, run: None, }); } @@ -2169,6 +2330,7 @@ mod tests { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, } } @@ -2199,6 +2361,8 @@ mod tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -2222,6 +2386,8 @@ mod tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: false, }); @@ -2378,6 +2544,7 @@ mod v_anchor_tests { oblique_angle: 0.0, is_backward: false, is_upside_down: false, + is_vertical: false, }; layout_mtext(&MTextRenderOpts { columns: Default::default(), @@ -2390,6 +2557,8 @@ mod v_anchor_tests { attach_h_anchor: 0.0, v_anchor: MTextVAnchor::Top, line_spacing_factor: 1.0, + exact_line_spacing: false, + rectangle_height: 0.0, vertical_text: false, want_glyph_boxes: true, }) diff --git a/src/entities/tolerance.rs b/src/entities/tolerance.rs index a1324e1b..4848af6f 100644 --- a/src/entities/tolerance.rs +++ b/src/entities/tolerance.rs @@ -3,7 +3,9 @@ use acadrust::entities::Tolerance; use crate::command::EntityTransform; use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; -use crate::scene::convert::acad_to_render::{GlyphRun, TextStroke, RenderEntity, RenderObject}; +use crate::scene::convert::acad_to_render::{ + GlyphRun, RenderEntity, RenderObject, TextPlane, TextStroke, +}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::model::wire_model::SnapHint; use crate::scene::text::lff; @@ -131,7 +133,7 @@ fn parse_cell(s: &str) -> Cell { /// (`\Fgdt.shx|b0|i0|c134|p6;j`), so match the name and skip to the `;` rather /// than demanding the bare `\Fgdt;` form — the parameterised spelling is legal /// and would otherwise drop the symbol silently. -fn symbol_font_switch(inner: &str) -> Option { +pub(crate) fn symbol_font_switch(inner: &str) -> Option { let rest = inner .strip_prefix("\\F") .or_else(|| inner.strip_prefix("\\f"))?; @@ -247,15 +249,13 @@ fn content_width( /// One text run of a feature-control frame, ready to become a `TextStroke` /// with a `GlyphRun` so the run can render as SDF glyph quads (or, when /// SDF is off, from `strokes`). `origin` is relative to the tolerance insertion -/// point (already rotated); `strokes` are the glyph polylines rotated about the -/// origin (no origin translation — the wire-builder adds the origin). +/// point; `strokes` remain in the frame's local plane. struct TolCell { text: String, font: String, origin: [f32; 2], strokes: Vec>, height: f32, - rotation: f32, width_factor: f32, oblique: f32, } @@ -263,8 +263,8 @@ struct TolCell { /// Tessellate a Tolerance entity's feature-control frame. /// /// Returns (`box_strokes`, `cells`): -/// - `box_strokes` — the outer border, row separators and column dividers as -/// 2-D polylines (rotated; run-less, so they always render as strokes) +/// - `box_strokes` — the outer border, row separators and column dividers in +/// frame-local coordinates /// - `cells` — one [`TolCell`] per non-empty cell, carrying its text + a /// `GlyphRun` so the cell renders as SDF text (frame stays geometry). fn tessellate_tolerance( @@ -341,13 +341,6 @@ fn tessellate_tolerance( .map(|row| row.iter().map(|c| cell_width(c)).collect()) .collect(); - // ── Transform helpers (local space — translation applied in tessellate.rs) ── - let angle = (tol.direction.y as f32).atan2(tol.direction.x as f32); - let (sa, ca) = angle.sin_cos(); - - // Rotate only; origin is kept as f64 and applied later with full precision. - let rot = |x: f32, y: f32| -> [f32; 2] { [x * ca - y * sa, x * sa + y * ca] }; - let mut box_out: Vec> = Vec::new(); let mut cells: Vec = Vec::new(); @@ -370,11 +363,11 @@ fn tessellate_tolerance( let y0 = row_bottom(ri); let y1 = y0 + cell_h; box_out.push(vec![ - rot(0.0, y0), - rot(rw, y0), - rot(rw, y1), - rot(0.0, y1), - rot(0.0, y0), + [0.0, y0], + [rw, y0], + [rw, y1], + [0.0, y1], + [0.0, y0], ]); } @@ -385,15 +378,13 @@ fn tessellate_tolerance( let mut x_cursor = 0.0_f32; for w in widths.iter().take(widths.len().saturating_sub(1)) { x_cursor += w; - box_out.push(vec![rot(x_cursor, y0), rot(x_cursor, y1)]); + box_out.push(vec![[x_cursor, y0], [x_cursor, y1]]); } } // ── Text content per cell ───────────────────────────────────────────── - // Each cell becomes a TolCell: its origin is the (rotated) cell position - // relative to the insertion point; its `strokes` are the glyph polylines - // rotated about that origin (used only when SDF is off). The GlyphRun the - // caller attaches lets the cell render as SDF text. + // Each cell stays in frame-local coordinates; the renderer maps the frame + // into its world-space plane. for (ri, row) in rows.iter().enumerate() { // The compartment is 2h tall and the character h, so an h/2 margin // centres the text on the row's centreline. @@ -427,21 +418,17 @@ fn tessellate_tolerance( &font, &text, ); - // Glyph polylines rotated about the run's origin (no origin - // translation — the wire-builder adds `origin`). let strokes: Vec> = local_strokes .into_iter() - .map(|pl| pl.into_iter().map(|[px, py]| rot(px, py)).collect()) .filter(|pl: &Vec<[f32; 2]>| !pl.is_empty()) .collect(); let advance = run_advance(&text, &font, h, width_factor, oblique); cells.push(TolCell { text, font, - origin: rot(run_x, row_y), + origin: [run_x, row_y], strokes, height: h, - rotation: angle, width_factor, oblique, }); @@ -455,6 +442,24 @@ fn tessellate_tolerance( (box_out, cells) } +pub(crate) fn preview_strokes( + tol: &Tolerance, + doc: &acadrust::CadDocument, +) -> Vec> { + tessellate_tolerance(tol, doc).0 +} + +fn frame_basis(tol: &Tolerance) -> (glam::DVec3, glam::DVec3) { + let normal = glam::DVec3::new(tol.normal.x, tol.normal.y, tol.normal.z) + .normalize_or(glam::DVec3::Z); + let direction = glam::DVec3::new(tol.direction.x, tol.direction.y, tol.direction.z); + let fallback = crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)).0; + let fallback = glam::DVec3::new(fallback.0, fallback.1, fallback.2); + let x_axis = (direction - normal * direction.dot(normal)).normalize_or(fallback); + let y_axis = normal.cross(x_axis).normalize_or(glam::DVec3::Y); + (x_axis, y_axis) +} + // ── RenderConvertible ────────────────────────────────────────────────────────── impl RenderConvertible for Tolerance { @@ -471,7 +476,18 @@ impl RenderConvertible for Tolerance { // Build the feature-control frame in local space; origin stored as f64. let (box_strokes, cells) = tessellate_tolerance(self, document); - let ins = [self.insertion_point.x, self.insertion_point.y]; + let ins = glam::DVec3::new( + self.insertion_point.x, + self.insertion_point.y, + self.insertion_point.z, + ); + let (x_axis, y_axis) = frame_basis(self); + let plane = |origin: glam::DVec3| TextPlane { + origin: origin.to_array(), + scale_origin: ins.to_array(), + x_axis: x_axis.to_array(), + y_axis: y_axis.to_array(), + }; let style = resolve_dim_style(self, document); let xd = &self.common.extended_data; let explicit_color = |index: Option| { @@ -520,9 +536,10 @@ impl RenderConvertible for Tolerance { if !box_strokes.is_empty() { groups.push(TextStroke { strokes: box_strokes, - origin: ins, + origin: [ins.x, ins.y], color: frame_color, fill_tris: vec![], + plane: Some(plane(ins)), run: None, }); } @@ -530,16 +547,21 @@ impl RenderConvertible for Tolerance { groups.push(TextStroke { strokes: cell.strokes, origin: [ - ins[0] + cell.origin[0] as f64, - ins[1] + cell.origin[1] as f64, + ins.x + x_axis.x * cell.origin[0] as f64 + + y_axis.x * cell.origin[1] as f64, + ins.y + x_axis.y * cell.origin[0] as f64 + + y_axis.y * cell.origin[1] as f64, ], color: text_color, fill_tris: vec![], + plane: Some(plane( + ins + x_axis * cell.origin[0] as f64 + y_axis * cell.origin[1] as f64, + )), run: Some(GlyphRun { text: cell.text, font: cell.font, height: cell.height, - rotation: cell.rotation, + rotation: 0.0, width_factor: cell.width_factor, oblique: cell.oblique, // `tracking` scales the font's own letter spacing, so 0 diff --git a/src/modules/annotate/mtext.rs b/src/modules/annotate/mtext.rs index a6a6d6d6..6af69039 100644 --- a/src/modules/annotate/mtext.rs +++ b/src/modules/annotate/mtext.rs @@ -1,7 +1,11 @@ -use crate::command::{CadCommand, CmdResult}; +use acadrust::entities::mtext::AttachmentPoint; +use acadrust::types::Vector3; +use acadrust::MText; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, DynField, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; use crate::scene::model::wire_model::WireModel; -use glam::DVec3; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/mtext.svg")); @@ -15,20 +19,180 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] enum Step { - InsertPoint, + FirstCorner, + OppositeCorner, + Height, + Justify, + LineSpacing, + Rotation, + Style, + Width, + ColumnMode, + ColumnCount, + ColumnWidth, + ColumnGutter, } pub struct MTextCommand { step: Step, + first: Option, + plane: WorkingPlane, height: f64, + style: String, + attachment: AttachmentPoint, + line_spacing: f64, + rotation: f64, + width: Option, + column_type: i16, + column_count: i32, + column_width: f64, + column_gutter: f64, } impl MTextCommand { - pub fn with_height(height: f64) -> Self { + pub fn with_defaults(height: f64, style: String) -> Self { Self { - step: Step::InsertPoint, - height, + step: Step::FirstCorner, + first: None, + plane: WorkingPlane::default(), + height: height.max(1e-6), + style, + attachment: AttachmentPoint::TopLeft, + line_spacing: 1.0, + rotation: 0.0, + width: None, + column_type: 0, + column_count: 2, + column_width: 0.0, + column_gutter: 0.0, + } + } + + fn resume_point_step(&mut self) { + self.step = if self.first.is_some() { + Step::OppositeCorner + } else { + Step::FirstCorner + }; + } + + fn point_options() -> Vec { + vec![ + CmdOption::new("Height", "HEIGHT"), + CmdOption::new("Justify", "JUSTIFY"), + CmdOption::new("Line spacing", "LINESPACING"), + CmdOption::new("Rotation", "ROTATION"), + CmdOption::new("Style", "STYLE"), + CmdOption::new("Width", "WIDTH"), + CmdOption::new("Columns", "COLUMNS"), + ] + } + + fn begin_option(&mut self, keyword: &str) -> bool { + self.step = match keyword { + "H" | "HEIGHT" => Step::Height, + "J" | "JUSTIFY" => Step::Justify, + "L" | "LINESPACING" | "LINE SPACING" => Step::LineSpacing, + "R" | "ROTATION" => Step::Rotation, + "S" | "STYLE" => Step::Style, + "W" | "WIDTH" => Step::Width, + "C" | "COLUMNS" => Step::ColumnMode, + _ => return false, + }; + true + } + + fn boundary_geometry(&self, opposite: DVec3) -> (DVec3, f64, f64) { + let first = self.first.unwrap_or(opposite); + let local = self.plane.vector_to_local(opposite - first); + let (sin, cos) = self.rotation.sin_cos(); + let picked_horizontal = local.x * cos + local.y * sin; + let vertical = -local.x * sin + local.y * cos; + let width = self.width.unwrap_or(picked_horizontal.abs()); + let horizontal = if self.width.is_some() { + width.copysign(if picked_horizontal.abs() > 1e-12 { + picked_horizontal + } else { + 1.0 + }) + } else { + picked_horizontal + }; + let (xmin, xmax) = (horizontal.min(0.0), horizontal.max(0.0)); + let (ymin, ymax) = (vertical.min(0.0), vertical.max(0.0)); + let horizontal_anchor = match self.attachment { + AttachmentPoint::TopLeft + | AttachmentPoint::MiddleLeft + | AttachmentPoint::BottomLeft => 0.0, + AttachmentPoint::TopCenter + | AttachmentPoint::MiddleCenter + | AttachmentPoint::BottomCenter => 0.5, + AttachmentPoint::TopRight + | AttachmentPoint::MiddleRight + | AttachmentPoint::BottomRight => 1.0, + }; + let vertical_anchor = match self.attachment { + AttachmentPoint::TopLeft + | AttachmentPoint::TopCenter + | AttachmentPoint::TopRight => 1.0, + AttachmentPoint::MiddleLeft + | AttachmentPoint::MiddleCenter + | AttachmentPoint::MiddleRight => 0.5, + AttachmentPoint::BottomLeft + | AttachmentPoint::BottomCenter + | AttachmentPoint::BottomRight => 0.0, + }; + let anchor_x = xmin + (xmax - xmin) * horizontal_anchor; + let anchor_y = ymin + (ymax - ymin) * vertical_anchor; + let offset = DVec3::new( + anchor_x * cos - anchor_y * sin, + anchor_x * sin + anchor_y * cos, + 0.0, + ); + ( + first + self.plane.vector_to_world(offset), + width, + vertical.abs(), + ) + } + + fn open_editor(&self, opposite: DVec3) -> CmdResult { + let (insertion, width, boundary_height) = self.boundary_geometry(opposite); + let mut template = MText::default(); + template.insertion_point = Vector3::new(insertion.x, insertion.y, insertion.z); + template.height = self.height; + template.rectangle_width = width.max(0.0); + template.rectangle_height = (boundary_height > 1e-9).then_some(boundary_height); + template.rotation = self.rotation; + template.style = self.style.clone(); + template.attachment_point = self.attachment; + template.line_spacing_factor = self.line_spacing; + template.column_data.column_type = self.column_type; + if self.column_type != 0 { + template.column_data.column_count = + crate::entities::text_support::clamp_mtext_column_count(self.column_count); + template.column_data.auto_height = self.column_type == 2; + template.column_data.width = if self.column_width > 0.0 { + self.column_width + } else if width > 0.0 { + width + } else { + self.height * 10.0 + }; + template.column_data.gutter = if self.column_gutter > 0.0 { + self.column_gutter + } else { + self.height + }; + } + CmdResult::OpenMTextEditor { + pos: insertion, + handle: None, + initial: String::new(), + height: self.height, + template: Some(Box::new(template)), } } } @@ -38,34 +202,208 @@ impl CadCommand for MTextCommand { "MTEXT" } + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("MTEXT Specify insertion point:").into_owned(), + match self.step { + Step::FirstCorner => t!("MTEXT Specify first corner:").into_owned(), + Step::OppositeCorner => t!("MTEXT Specify opposite corner:").into_owned(), + Step::Height => crate::tf!("MTEXT Specify text height <{}>:", self.height).into_owned(), + Step::Justify => t!("MTEXT Enter justification [TL / TC / TR / ML / MC / MR / BL / BC / BR] :").into_owned(), + Step::LineSpacing => crate::tf!("MTEXT Enter line spacing factor (0.25-4.00) <{}>:", self.line_spacing).into_owned(), + Step::Rotation => crate::tf!("MTEXT Specify rotation angle <{}>:", self.rotation.to_degrees()).into_owned(), + Step::Style => crate::tf!("MTEXT Enter text style <{}>:", self.style).into_owned(), + Step::Width => crate::tf!("MTEXT Specify boundary width <{}>:", self.width.unwrap_or(0.0)).into_owned(), + Step::ColumnMode => t!("MTEXT Columns [None / Static / Dynamic] :").into_owned(), + Step::ColumnCount => crate::tf!("MTEXT Enter column count <{}>:", self.column_count).into_owned(), + Step::ColumnWidth => crate::tf!("MTEXT Enter column width <{}>:", self.column_width).into_owned(), + Step::ColumnGutter => crate::tf!("MTEXT Enter column gutter <{}>:", self.column_gutter).into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place editor (toolbar + text area + live preview). - CmdResult::OpenMTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::FirstCorner | Step::OppositeCorner => Self::point_options(), + Step::Justify => ["TL", "TC", "TR", "ML", "MC", "MR", "BL", "BC", "BR"] + .into_iter() + .map(|value| CmdOption::new(value, value)) + .collect(), + Step::ColumnMode => vec![ + CmdOption::new("None", "NONE"), + CmdOption::new("Static", "STATIC"), + CmdOption::new("Dynamic", "DYNAMIC"), + ], + _ => Vec::new(), + } + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + } + + fn wants_text_input(&self) -> bool { + true + } + + fn wants_text_with_spaces(&self) -> bool { + matches!(self.step, Step::Style) + } + + fn dyn_field(&self) -> DynField { + match self.step { + Step::Rotation => DynField::Angle, + Step::Height + | Step::LineSpacing + | Step::Width + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => DynField::Scalar, + _ => DynField::Point, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let trimmed = text.trim(); + let upper = trimmed.to_ascii_uppercase(); + if matches!(self.step, Step::FirstCorner | Step::OppositeCorner) + && self.begin_option(&upper) + { + return Some(CmdResult::NeedPoint); + } + + match self.step { + Step::Height => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.height = value; + self.resume_point_step(); + } + Step::Justify => { + self.attachment = match upper.as_str() { + "TL" => AttachmentPoint::TopLeft, + "TC" => AttachmentPoint::TopCenter, + "TR" => AttachmentPoint::TopRight, + "ML" => AttachmentPoint::MiddleLeft, + "MC" => AttachmentPoint::MiddleCenter, + "MR" => AttachmentPoint::MiddleRight, + "BL" => AttachmentPoint::BottomLeft, + "BC" => AttachmentPoint::BottomCenter, + "BR" => AttachmentPoint::BottomRight, + _ => return None, + }; + self.resume_point_step(); + } + Step::LineSpacing => { + let value = trimmed.replace(',', ".").parse::().ok()?; + if !(0.25..=4.0).contains(&value) { + return None; + } + self.line_spacing = value; + self.resume_point_step(); + } + Step::Rotation => { + self.rotation = crate::entities::common::parse_typed_direction(trimmed)?; + self.resume_point_step(); + } + Step::Style => { + if trimmed.is_empty() { + return None; + } + self.style = trimmed.to_string(); + self.resume_point_step(); + } + Step::Width => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.width = Some(value); + self.resume_point_step(); + } + Step::ColumnMode => { + self.column_type = match upper.as_str() { + "N" | "NONE" => 0, + "S" | "STATIC" => 1, + "D" | "DYNAMIC" => 2, + _ => return None, + }; + if self.column_type == 0 { + self.resume_point_step(); + } else { + self.step = Step::ColumnCount; + } + } + Step::ColumnCount => { + let value = trimmed.parse::().ok()?; + if !(1..=crate::entities::text_support::MAX_MTEXT_COLUMNS).contains(&value) { + return None; + } + self.column_count = value; + self.step = Step::ColumnWidth; + } + Step::ColumnWidth => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value <= 0.0 { + return None; + } + self.column_width = value; + self.step = Step::ColumnGutter; + } + Step::ColumnGutter => { + let value = crate::entities::common::parse_typed_length(trimmed)?; + if value < 0.0 { + return None; + } + self.column_gutter = value; + self.resume_point_step(); + } + Step::FirstCorner | Step::OppositeCorner => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::FirstCorner => { + self.first = Some(point); + self.step = Step::OppositeCorner; + CmdResult::NeedPoint + } + Step::OppositeCorner => self.open_editor(point), + _ => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Height + | Step::Justify + | Step::LineSpacing + | Step::Rotation + | Step::Style + | Step::Width + | Step::ColumnMode + | Step::ColumnCount + | Step::ColumnWidth + | Step::ColumnGutter => { + self.resume_point_step(); + CmdResult::NeedPoint + } + _ => CmdResult::Cancel, + } } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); // MTextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["MTEXT"] }); diff --git a/src/modules/annotate/text.rs b/src/modules/annotate/text.rs index c00ad981..90d815a6 100644 --- a/src/modules/annotate/text.rs +++ b/src/modules/annotate/text.rs @@ -1,7 +1,14 @@ -use crate::command::{CadCommand, CmdResult}; -use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use acadrust::entities::{ + Text, TextHorizontalAlignment as HA, TextVerticalAlignment as VA, +}; +use acadrust::tables::TextStyle; +use acadrust::types::Vector3; use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, WorkingPlane}; +use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::creation_style::TextCreationDefaults; +use crate::scene::model::wire_model::WireModel; use crate::t; pub const ICON: IconKind = IconKind::Svg(include_bytes!("../../../assets/icons/text.svg")); @@ -15,57 +22,434 @@ pub fn tool() -> ToolDef { } } +#[derive(Clone, Copy, PartialEq, Eq)] enum Step { - InsertPoint, + Start, + Justification, + Style, + Height, + Rotation, + SecondPoint, } pub struct TextCommand { step: Step, + plane: WorkingPlane, + first_point: Option, + second_point: Option, + horizontal: HA, + vertical: VA, + style_name: String, + styles: Vec, height: f64, + rotation: f64, + width_factor: f64, + oblique_angle: f64, + fixed_height: bool, + annotative: bool, + annotation_multiplier: f64, + last_display_height: Option, + last_entity: Option, } impl TextCommand { - pub fn with_height(height: f64) -> Self { - Self { - step: Step::InsertPoint, - height, + pub fn with_defaults( + defaults: TextCreationDefaults, + styles: Vec, + annotation_multiplier: f64, + ) -> Self { + let current_height = defaults.height; + let mut command = Self { + step: Step::Start, + plane: WorkingPlane::default(), + first_point: None, + second_point: None, + horizontal: HA::Left, + vertical: VA::Baseline, + style_name: defaults.style_name, + styles, + height: defaults.height, + rotation: 0.0, + width_factor: defaults.width_factor, + oblique_angle: defaults.oblique_angle, + fixed_height: false, + annotative: false, + annotation_multiplier: annotation_multiplier.max(1.0e-9), + last_display_height: None, + last_entity: None, + }; + let style = command.style_name.clone(); + command.select_style(&style); + if !command.fixed_height { + command.height = current_height; } + command + } + + fn select_style(&mut self, name: &str) -> bool { + let Some(style) = self + .styles + .iter() + .find(|style| style.name.eq_ignore_ascii_case(name)) + .cloned() + else { + return false; + }; + self.style_name = style.name; + self.fixed_height = style.height > 1.0e-9; + if self.fixed_height { + self.height = style.height; + } else if style.last_height > 1.0e-9 { + self.height = style.last_height; + } + self.width_factor = style.width_factor.max(0.01); + self.oblique_angle = style.oblique_angle.clamp( + -85.0_f64.to_radians(), + 85.0_f64.to_radians(), + ); + self.annotative = style.annotative; + true + } + + fn set_justification(&mut self, value: &str) -> bool { + let normalized = value.trim().to_ascii_uppercase().replace([' ', '-'], ""); + let alignment = match normalized.as_str() { + "L" | "LEFT" => (HA::Left, VA::Baseline), + "C" | "CENTER" => (HA::Center, VA::Baseline), + "R" | "RIGHT" => (HA::Right, VA::Baseline), + "A" | "ALIGNED" | "ALIGN" => (HA::Aligned, VA::Baseline), + "M" | "MIDDLE" => (HA::Middle, VA::Baseline), + "F" | "FIT" => (HA::Fit, VA::Baseline), + "TL" | "TOPLEFT" => (HA::Left, VA::Top), + "TC" | "TOPCENTER" => (HA::Center, VA::Top), + "TR" | "TOPRIGHT" => (HA::Right, VA::Top), + "ML" | "MIDDLELEFT" => (HA::Left, VA::Middle), + "MC" | "MIDDLECENTER" => (HA::Center, VA::Middle), + "MR" | "MIDDLERIGHT" => (HA::Right, VA::Middle), + "BL" | "BOTTOMLEFT" => (HA::Left, VA::Bottom), + "BC" | "BOTTOMCENTER" => (HA::Center, VA::Bottom), + "BR" | "BOTTOMRIGHT" => (HA::Right, VA::Bottom), + _ => return false, + }; + self.horizontal = alignment.0; + self.vertical = alignment.1; + true + } + + fn is_two_point(&self) -> bool { + matches!(self.horizontal, HA::Aligned | HA::Fit) + } + + fn after_first_point(&mut self) -> CmdResult { + if self.is_two_point() { + self.step = Step::SecondPoint; + } else if self.fixed_height { + self.step = Step::Rotation; + } else { + self.step = Step::Height; + } + CmdResult::NeedPoint + } + + fn after_second_point(&mut self) -> CmdResult { + if matches!(self.horizontal, HA::Fit) && !self.fixed_height { + self.step = Step::Height; + CmdResult::NeedPoint + } else { + self.open_editor() + } + } + + fn make_entity(&self) -> Option { + let first = self.plane.to_local(self.first_point?); + let mut text = Text::with_value("", Vector3::new(first.x, first.y, first.z)) + .with_height(self.height.max(1.0e-9)); + text.style = self.style_name.clone(); + text.width_factor = self.width_factor.max(0.01); + text.oblique_angle = self.oblique_angle; + text.rotation = self.rotation; + text.horizontal_alignment = self.horizontal; + text.vertical_alignment = self.vertical; + text.alignment_point = if self.is_two_point() { + let second = self.plane.to_local(self.second_point?); + Some(Vector3::new(second.x, second.y, second.z)) + } else if matches!((self.horizontal, self.vertical), (HA::Left, VA::Baseline)) { + None + } else { + Some(Vector3::new(first.x, first.y, first.z)) + }; + Some(text) + } + + fn open_editor(&mut self) -> CmdResult { + let Some(entity) = self.make_entity() else { + return CmdResult::NeedPoint; + }; + let pos = self.first_point.unwrap_or(DVec3::ZERO); + self.last_display_height = None; + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } + } + + fn open_next_line(&mut self) -> CmdResult { + let Some(mut entity) = self.last_entity.take() else { + return CmdResult::Cancel; + }; + let angle = if matches!(entity.horizontal_alignment, HA::Aligned | HA::Fit) { + entity.alignment_point.map_or(entity.rotation, |point| { + (point.y - entity.insertion_point.y) + .atan2(point.x - entity.insertion_point.x) + }) + } else { + entity.rotation + }; + let fallback_height = entity.height.max(1.0e-9) + * if self.annotative { + self.annotation_multiplier + } else { + 1.0 + }; + let spacing = self + .last_display_height + .take() + .unwrap_or(fallback_height) + .max(1.0e-9) + * 1.666_666_666_7; + let delta = Vector3::new(angle.sin() * spacing, -angle.cos() * spacing, 0.0); + entity.insertion_point = entity.insertion_point + delta; + if let Some(point) = entity.alignment_point.as_mut() { + *point = *point + delta; + } + entity.value.clear(); + let local = DVec3::new( + entity.insertion_point.x, + entity.insertion_point.y, + entity.insertion_point.z, + ); + let pos = self.plane.to_world(local); + self.last_entity = Some(entity.clone()); + CmdResult::SuspendForTextInput { pos, entity } } } impl CadCommand for TextCommand { + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.plane = plane; + } + fn name(&self) -> &'static str { "TEXT" } fn prompt(&self) -> String { - match &self.step { - Step::InsertPoint => t!("TEXT Specify insertion point:").into_owned(), + match self.step { + Step::Start => format!( + "{}\n{}", + crate::tf!( + "TEXT Current style: {}, Height: {}, Annotative: {}", + self.style_name, + self.height, + if self.annotative { "Yes" } else { "No" } + ), + t!("TEXT Specify start point or [Justify/Style]:") + ), + Step::Justification => t!( + "TEXT Enter justification [Left/Center/Right/Aligned/Middle/Fit/TL/TC/TR/ML/MC/MR/BL/BC/BR]:" + ) + .into_owned(), + Step::Style => crate::tf!("TEXT Enter style name <{}>:", self.style_name).into_owned(), + Step::Height if self.annotative => { + crate::tf!("TEXT Specify paper text height <{}>:", self.height).into_owned() + } + Step::Height => crate::tf!("TEXT Specify height <{}>:", self.height).into_owned(), + Step::Rotation => crate::tf!( + "TEXT Specify rotation angle <{}>:", + self.rotation.to_degrees() + ) + .into_owned(), + Step::SecondPoint => t!("TEXT Specify second endpoint:").into_owned(), } } - fn on_point(&mut self, pt: DVec3) -> CmdResult { - // Hand off to the in-place plain-text editor anchored at the click. - CmdResult::OpenTextEditor { - pos: pt, - handle: None, - initial: String::new(), - height: self.height, + fn options(&self) -> Vec { + match self.step { + Step::Start => vec![ + CmdOption::new(t!("Justify").as_ref(), "J"), + CmdOption::new(t!("Style").as_ref(), "ST"), + ], + Step::Justification => [ + ("Left", "L"), ("Center", "C"), ("Right", "R"), + ("Aligned", "A"), ("Middle", "M"), ("Fit", "F"), + ("TL", "TL"), ("TC", "TC"), ("TR", "TR"), + ("ML", "ML"), ("MC", "MC"), ("MR", "MR"), + ("BL", "BL"), ("BC", "BC"), ("BR", "BR"), + ] + .into_iter() + .map(|(label, keyword)| CmdOption::new(label, keyword)) + .collect(), + Step::Style => self + .styles + .iter() + .map(|style| CmdOption::new(&style.name, &style.name)) + .collect(), + _ => Vec::new(), + } + } + + fn wants_text_input(&self) -> bool { + matches!( + self.step, + Step::Justification | Step::Style | Step::Height | Step::Rotation + ) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::Start) + } + + fn on_text_input(&mut self, text: &str) -> Option { + let token = text.trim(); + let upper = token.to_ascii_uppercase(); + match self.step { + Step::Start => match upper.as_str() { + "J" | "JUSTIFY" | "JUSTIFICATION" => self.step = Step::Justification, + "S" | "ST" | "STYLE" => self.step = Step::Style, + _ => return None, + }, + Step::Justification => { + if !self.set_justification(token) { + return None; + } + self.step = Step::Start; + } + Step::Style => { + if !self.select_style(token) { + return None; + } + self.step = Step::Start; + } + Step::Height => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() || value <= 1.0e-9 { + return None; + } + self.height = value; + if self.is_two_point() { + return Some(self.open_editor()); + } + self.step = Step::Rotation; + } + Step::Rotation => { + let value = token.replace(',', ".").parse::().ok()?; + if !value.is_finite() { + return None; + } + self.rotation = value.to_radians(); + return Some(self.open_editor()); + } + Step::SecondPoint => return None, + } + Some(CmdResult::NeedPoint) + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + match self.step { + Step::Start => { + self.first_point = Some(point); + self.second_point = None; + self.after_first_point() + } + Step::Height => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let value = self + .plane + .vector_to_local(point - first) + .truncate() + .length(); + if value <= 1.0e-9 { + return CmdResult::NeedPoint; + } + self.height = value; + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + let Some(angle) = self.plane.angle(first, point) else { + return CmdResult::NeedPoint; + }; + self.rotation = angle; + self.open_editor() + } + Step::SecondPoint => { + let Some(first) = self.first_point else { + return CmdResult::NeedPoint; + }; + if self + .plane + .vector_to_local(point - first) + .truncate() + .length() + <= 1.0e-9 + { + return CmdResult::NeedPoint; + } + self.second_point = Some(point); + self.after_second_point() + } + Step::Justification | Step::Style => CmdResult::NeedPoint, } } fn on_enter(&mut self) -> CmdResult { - CmdResult::Cancel + match self.step { + Step::Start => CmdResult::Cancel, + Step::Justification | Step::Style => { + self.step = Step::Start; + CmdResult::NeedPoint + } + Step::Height => { + if self.is_two_point() { + self.open_editor() + } else { + self.step = Step::Rotation; + CmdResult::NeedPoint + } + } + Step::Rotation => self.open_editor(), + Step::SecondPoint => CmdResult::NeedPoint, + } } + + fn on_editor_closed(&mut self, committed: bool) -> CmdResult { + if committed { + self.open_next_line() + } else { + CmdResult::Cancel + } + } + + fn on_editor_display_height(&mut self, height: f64) { + if height.is_finite() && height > 1.0e-9 { + self.last_display_height = Some(height); + } + } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } - fn on_mouse_move(&mut self, _pt: DVec3) -> Option { + fn on_mouse_move(&mut self, _point: DVec3) -> Option { None } } - -// ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); // TextCommand +inventory::submit!(crate::command::CommandRegistration { names: &["TEXT"] }); diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index 911d9dab..192835bc 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -25,13 +25,15 @@ pub fn tool() -> ToolDef { pub struct ToleranceCommand { text: String, + preview_strokes: Vec>, plane: WorkingPlane, } impl ToleranceCommand { - pub fn with_text(text: String) -> Self { + pub fn with_text(text: String, preview_strokes: Vec>) -> Self { Self { text, + preview_strokes, plane: WorkingPlane::default(), } } @@ -66,48 +68,17 @@ impl CadCommand for ToleranceCommand { } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - let normalized = self - .text - .replace("^J", "\n") - .replace("\\P", "\n") - .replace("\r\n", "\n") - .replace('\r', "\n"); - let rows: Vec> = normalized - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| { - let mut cells: Vec<&str> = line.split("%%v").collect(); - while cells.last().is_some_and(|cell| cell.trim().is_empty()) { - cells.pop(); - } - cells - }) - .filter(|cells| !cells.is_empty()) - .collect(); - let row_height = 0.35; - let cell_width = 0.5; let mut points = Vec::new(); - let mut segment = |a: DVec3, b: DVec3| { + for stroke in &self.preview_strokes { + if stroke.len() < 2 { + continue; + } if !points.is_empty() { points.push(DVec3::splat(f64::NAN)); } - points.push(a); - points.push(b); - }; - for (row_index, cells) in rows.iter().enumerate() { - let count = cells.len().max(1); - let y0 = -row_height * (row_index as f64 + 0.5); - let y1 = y0 + row_height; - let x1 = cell_width * count as f64; - let p = |x: f64, y: f64| pt + self.plane.x * x + self.plane.y * y; - segment(p(0.0, y0), p(x1, y0)); - segment(p(x1, y0), p(x1, y1)); - segment(p(x1, y1), p(0.0, y1)); - segment(p(0.0, y1), p(0.0, y0)); - for index in 1..count { - let x = cell_width * index as f64; - segment(p(x, y0), p(x, y1)); - } + points.extend(stroke.iter().map(|[x, y]| { + pt + self.plane.x * *x as f64 + self.plane.y * *y as f64 + })); } Some(WireModel { point_marker: None, diff --git a/src/modules/draw/modify/align.rs b/src/modules/draw/modify/align.rs index ad6122ef..8cd4109c 100644 --- a/src/modules/draw/modify/align.rs +++ b/src/modules/draw/modify/align.rs @@ -14,6 +14,7 @@ use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdResult, EntityTransform}; +use crate::scene::model::wire_model::WireModel; pub struct AlignCommand { state: AlignState, @@ -35,10 +36,16 @@ enum AlignState { } impl AlignCommand { - pub fn new() -> Self { + pub fn with_selection(handles: Vec) -> Self { + let state = if handles.is_empty() { + AlignState::Gathering + } else { + AlignState::Src1 + }; + Self { - state: AlignState::Gathering, - handles: vec![], + state, + handles, src1: None, dst1: None, src2: None, @@ -66,11 +73,26 @@ impl CadCommand for AlignCommand { } AlignState::Dst2 => t!("ALIGN Specify 2nd destination point:").into_owned(), AlignState::AskScale => { - t!("ALIGN Scale objects based on alignment points? [Y/N]:").into_owned() + t!( + "ALIGN Scale objects based on alignment points? [Yes / No]:" + ) + .into_owned() } } } + fn options(&self) -> Vec { + use crate::command::CmdOption; + + match self.state { + AlignState::AskScale => vec![ + CmdOption::new(t!("Yes").as_ref(), "Y"), + CmdOption::new(t!("No").as_ref(), "N"), + ], + _ => vec![], + } + } + fn is_selection_gathering(&self) -> bool { self.state == AlignState::Gathering } @@ -113,14 +135,17 @@ impl CadCommand for AlignCommand { if self.handles.is_empty() { return CmdResult::Cancel; } + self.state = AlignState::Src1; CmdResult::NeedPoint } + AlignState::Src2 => { - // Only 1 pair — pure translation + // One alignment pair only: translation. match (self.src1, self.dst1) { (Some(s), Some(d)) => { let delta = d - s; + CmdResult::TransformSelected( self.handles.clone(), EntityTransform::Translate(delta), @@ -129,10 +154,10 @@ impl CadCommand for AlignCommand { _ => CmdResult::Cancel, } } - AlignState::AskScale => { - // No scale (default N) - self.compute_align(false) - } + + // Default option shown as . + AlignState::AskScale => self.compute_align(false), + _ => CmdResult::Cancel, } } @@ -145,8 +170,76 @@ impl CadCommand for AlignCommand { if self.state != AlignState::AskScale { return None; } - let scale = text.trim().to_uppercase().starts_with('Y'); - Some(self.compute_align(scale)) + + match text.trim().to_ascii_lowercase().as_str() { + "y" | "yes" | "scale" => { + Some(self.compute_align(true)) + } + + "n" | "no" | "don't scale" | "dont scale" | "noscale" => { + Some(self.compute_align(false)) + } + + _ => Some(CmdResult::NeedPoint), + } + } + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + fn line(a: DVec3, b: DVec3, name: &str) -> WireModel { + WireModel::solid( + name.into(), + vec![ + [a.x as f32, a.y as f32, a.z as f32], + [b.x as f32, b.y as f32, b.z as f32], + ], + WireModel::CYAN, + false, + ) + } + + let mut out = Vec::new(); + + // Keep the first completed alignment pair visible while defining + // the second pair and while choosing the scale option. + if let (Some(src1), Some(dst1)) = (self.src1, self.dst1) { + match self.state { + AlignState::Src2 + | AlignState::Dst2 + | AlignState::AskScale => { + out.push(line(src1, dst1, "align_pair_1")); + } + _ => {} + } + } + + match self.state { + // First source has been picked: stretch its reference line + // to the cursor until the first destination is chosen. + AlignState::Dst1 => { + if let Some(src1) = self.src1 { + out.push(line(src1, pt, "align_pair_1_preview")); + } + } + + // Second source has been picked: stretch the second reference + // line to the cursor until its destination is chosen. + AlignState::Dst2 => { + if let Some(src2) = self.src2 { + out.push(line(src2, pt, "align_pair_2_preview")); + } + } + + // Once both pairs are complete, keep both visible while + // waiting for the Scale / No Scale decision. + AlignState::AskScale => { + if let (Some(src2), Some(dst2)) = (self.src2, self.dst2) { + out.push(line(src2, dst2, "align_pair_2")); + } + } + + _ => {} + } + + out } } diff --git a/src/modules/draw/modify/offset.rs b/src/modules/draw/modify/offset.rs index d3b5cc0a..20a988ac 100644 --- a/src/modules/draw/modify/offset.rs +++ b/src/modules/draw/modify/offset.rs @@ -415,19 +415,19 @@ fn entity_wire_pts(e: &EntityType) -> Vec<[f32; 3]> { // ── Command implementation ───────────────────────────────────────────────── enum Step { - /// Classic first step (#418): type the offset distance, press Enter / - /// Space to accept the last one, or choose Through mode. + /// Type the offset distance, accept the previous value with Enter, + /// choose Through, or pick the first point of a reference distance. Distance, - /// Pick the object to offset. `locked == None` is "through" mode: the - /// magnitude follows the cursor (perpendicular distance to the object). + + /// First reference point has been picked; the second point defines + /// the offset distance. + ReferenceSecond { first: DVec3 }, + SelectObject { locked: Option }, + PickSide { - /// The object(s) being offset — one from a pick, or the whole - /// pre-selection when OFFSET starts with objects selected (#422). targets: Vec, locked: Option, - /// Keep the side-pick step active and use each new result as the source - /// for the next offset. multiple: bool, }, } @@ -499,6 +499,18 @@ impl OffsetCommand { } CmdResult::NeedPoint } + fn accept_distance(&mut self, distance: f64) -> CmdResult { + let distance = distance.abs().max(1e-9); + + defaults::set_offset_dist(distance); + self.advance_from_distance(Some(distance)); + + let d = format!("{:.4}", distance); + + CmdResult::ReportMeasurement( + t!("OFFSET distance = %{d}", d = d).into_owned() + ) + } } impl CadCommand for OffsetCommand { @@ -510,7 +522,15 @@ impl CadCommand for OffsetCommand { match &self.step { Step::Distance => { let d = format!("{:.4}", defaults::get_offset_dist()); - t!("OFFSET Specify offset distance or [Through] <%{d}>:", d = d).into_owned() + t!( + "OFFSET Specify offset distance or first reference point [Through] <%{d}>:", + d = d + ) + .into_owned() + } + + Step::ReferenceSecond { .. } => { + t!("OFFSET Specify second reference point:").into_owned() } Step::SelectObject { .. } => { t!("OFFSET Select object to offset (Enter to finish):").into_owned() @@ -623,7 +643,9 @@ impl CadCommand for OffsetCommand { fn dyn_field(&self) -> crate::command::DynField { match self.step { Step::Distance | Step::PickSide { .. } => crate::command::DynField::Scalar, - _ => crate::command::DynField::Point, + + Step::ReferenceSecond { .. } + | Step::SelectObject { .. } => crate::command::DynField::Point, } } @@ -648,9 +670,7 @@ impl CadCommand for OffsetCommand { return Some(self.advance_from_distance(None)); } if let Some(d) = crate::entities::common::parse_typed_length(&t) { - let d = d.abs().max(1e-9); - defaults::set_offset_dist(d); - return Some(self.advance_from_distance(Some(d))); + return Some(self.accept_distance(d)); } Some(CmdResult::NeedPoint) } @@ -698,12 +718,32 @@ impl CadCommand for OffsetCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + match &self.step { + Step::Distance => { + self.step = Step::ReferenceSecond { first: pt }; + return CmdResult::NeedPoint; + } + + Step::ReferenceSecond { first } => { + let distance = first.distance(pt); + + if distance <= 1e-9 { + return CmdResult::NeedPoint; + } + + return self.accept_distance(distance); + } + + _ => {} + } + let (locked, targets, multiple) = match &self.step { Step::PickSide { locked, targets, multiple, } => (*locked, targets.clone(), *multiple), + _ => return CmdResult::NeedPoint, }; // Each target offsets by its own through-distance (or the locked @@ -745,6 +785,26 @@ impl CadCommand for OffsetCommand { } fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + if let Step::ReferenceSecond { first } = &self.step { + return vec![WireModel::solid( + "offset_reference_distance".into(), + vec![ + [ + first.x as f32, + first.y as f32, + first.z as f32, + ], + [ + pt.x as f32, + pt.y as f32, + pt.z as f32, + ], + ], + WireModel::CYAN, + false, + )]; + } + let (locked, targets) = match &self.step { Step::PickSide { locked, targets, .. } => (*locked, targets.clone()), _ => return vec![], @@ -779,7 +839,7 @@ impl CadCommand for OffsetCommand { // the "repeat the same value with just Space" flow (#418). Step::Distance => { let d = defaults::get_offset_dist(); - self.advance_from_distance(Some(d.abs().max(1e-9))) + self.accept_distance(d) } _ => CmdResult::Cancel, } diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index 23b7d205..59259f0f 100644 --- a/src/plugin/v4_support.rs +++ b/src/plugin/v4_support.rs @@ -42,6 +42,15 @@ pub fn on_tab_closed(tab_id: u64) { broadcast(HostNotification::DocumentTabClosed { tab_id }); } +/// Broadcast a selection change to V4 plugins. +/// +/// `HostNotification::SelectionChangedV4` carries the active tab id and the +/// current selection set. The caller is responsible for only calling this when +/// the selection actually changed; see `OpenCADStudio::notify_plugins_selection_changed`. +pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec) { + broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); +} + /// V4 notification handler installed on the plugin manager. /// /// Forwards REPL status messages to the log; other notifications are handled diff --git a/src/scene/cache/properties.rs b/src/scene/cache/properties.rs index 2dbe971e..93fde9d1 100644 --- a/src/scene/cache/properties.rs +++ b/src/scene/cache/properties.rs @@ -94,7 +94,9 @@ pub fn general_section(entity: &EntityType) -> PropSection { ], }; - if matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) + if matches!(entity, EntityType::Text(_)) + || matches!(entity, EntityType::LwPolyline(polyline) if crate::entities::lwpolyline::is_rectangle(polyline)) + || matches!(entity, EntityType::MText(_)) || matches!(entity, EntityType::Tolerance(_)) { section.props.retain(|prop| prop.field != "handle"); diff --git a/src/scene/convert/acad_to_render.rs b/src/scene/convert/acad_to_render.rs index 4ebc04a7..478da682 100644 --- a/src/scene/convert/acad_to_render.rs +++ b/src/scene/convert/acad_to_render.rs @@ -24,6 +24,7 @@ pub struct TextStroke { pub origin: [f64; 2], pub color: Option<[f32; 3]>, pub fill_tris: Vec<[f32; 2]>, + pub plane: Option, /// Layout inputs to rebuild this run as per-glyph SDF quads (see /// `scene::text::glyph_quads`). `Some` on runs wired for the SDF text /// renderer; `None` leaves the run to the stroke path only. Heights are @@ -32,6 +33,14 @@ pub struct TextStroke { pub run: Option, } +#[derive(Clone, Copy, Debug)] +pub struct TextPlane { + pub origin: [f64; 3], + pub scale_origin: [f64; 3], + pub x_axis: [f64; 3], + pub y_axis: [f64; 3], +} + /// Per-run text-layout inputs needed to reproduce a run as SDF glyph quads. #[derive(Clone, Debug)] pub struct GlyphRun { diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 5ce5e56d..02d0dcd5 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -528,7 +528,7 @@ fn tessellate_entity_inner( if let EntityType::Viewport(vp) = e { // The sheet viewport (overall/id=1) is never shown — it represents the // paper boundary, not a user-defined content window. - if !Scene::is_content_viewport(vp) { + if Scene::is_sheet_viewport(document, vp) { return vec![]; } let is_active = active_viewport == Some(h); diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index d474c5f1..108c2515 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -78,6 +78,81 @@ fn oriented_text_corners( ] } +fn oriented_mtext_corner_groups( + verts: &[crate::scene::pipeline::text_gpu::TextVertex], + text: &acadrust::MText, + rotation: f64, + pad: f64, + annotation_scale: f64, +) -> Vec<[[f64; 2]; 4]> { + let columns = &text.column_data; + let count = crate::entities::text_support::clamp_mtext_column_count(columns.column_count) + as usize; + if columns.column_type == 0 || count <= 1 || columns.width <= 0.0 { + return vec![oriented_text_corners( + verts, + [text.insertion_point.x, text.insertion_point.y], + rotation, + pad, + )]; + } + + let width = columns.width * annotation_scale; + let gutter = columns.gutter.max(0.0) * annotation_scale; + let total_width = width * count as f64 + gutter * count.saturating_sub(1) as f64; + let anchor = match text.attachment_point { + acadrust::entities::mtext::AttachmentPoint::TopCenter + | acadrust::entities::mtext::AttachmentPoint::MiddleCenter + | acadrust::entities::mtext::AttachmentPoint::BottomCenter => 0.5, + acadrust::entities::mtext::AttachmentPoint::TopRight + | acadrust::entities::mtext::AttachmentPoint::MiddleRight + | acadrust::entities::mtext::AttachmentPoint::BottomRight => 1.0, + _ => 0.0, + }; + let block_left = -anchor * total_width; + let origin = [text.insertion_point.x, text.insertion_point.y]; + let (sin_r, cos_r) = rotation.sin_cos(); + let mut bounds = vec![[f64::MAX, f64::MAX, f64::MIN, f64::MIN]; count]; + for vertex in verts { + let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0]; + let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1]; + let local_x = x * cos_r + y * sin_r; + let local_y = -x * sin_r + y * cos_r; + let stride = width + gutter; + let physical = ((local_x - block_left) / stride) + .floor() + .clamp(0.0, count.saturating_sub(1) as f64) as usize; + bounds[physical][0] = bounds[physical][0].min(local_x); + bounds[physical][1] = bounds[physical][1].min(local_y); + bounds[physical][2] = bounds[physical][2].max(local_x); + bounds[physical][3] = bounds[physical][3].max(local_y); + } + let to_world = |x: f64, y: f64| { + [ + origin[0] + x * cos_r - y * sin_r, + origin[1] + x * sin_r + y * cos_r, + ] + }; + bounds + .into_iter() + .filter(|bounds| bounds[0] <= bounds[2] && bounds[1] <= bounds[3]) + .map(|bounds| { + let [left, bottom, right, top] = [ + bounds[0] - pad, + bounds[1] - pad, + bounds[2] + pad, + bounds[3] + pad, + ]; + [ + to_world(left, bottom), + to_world(right, bottom), + to_world(right, top), + to_world(left, top), + ] + }) + .collect() +} + pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> { let EntityType::MText(text) = entity else { return None; @@ -633,7 +708,14 @@ pub fn tessellate( // stay a roughly constant on-screen size; otherwise the header-driven path. let te = crate::entities::point::relative_render(entity, document, world_per_pixel) .or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel)) - .or_else(|| convert(entity, document)); + .or_else(|| match entity { + EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale( + text, + document, + anno_scale, + )), + _ => convert(entity, document), + }); if let Some(te) = te { match te.object { // ── Text / MText: pre-tessellated glyph strokes ─────────────── @@ -733,9 +815,32 @@ pub fn tessellate( } bin_first[bi] = false; for &[x, y] in stroke { - let xv = x as f64 * anno + slx_v; - let yv = y as f64 * anno + sly_v; - let (h, l) = split_ds_xyz(xv, yv, elev_v); + let world = if let Some(plane) = group.plane { + let scaled_origin = [ + plane.scale_origin[0] + + (plane.origin[0] - plane.scale_origin[0]) * anno, + plane.scale_origin[1] + + (plane.origin[1] - plane.scale_origin[1]) * anno, + plane.scale_origin[2] + + (plane.origin[2] - plane.scale_origin[2]) * anno, + ]; + let x = x as f64 * anno; + let y = y as f64 * anno; + [ + scaled_origin[0] + + plane.x_axis[0] * x + + plane.y_axis[0] * y, + scaled_origin[1] + + plane.x_axis[1] * x + + plane.y_axis[1] * y, + scaled_origin[2] + + plane.x_axis[2] * x + + plane.y_axis[2] * y, + ] + } else { + [x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v] + }; + let (h, l) = split_ds_xyz(world[0], world[1], world[2]); bins[bi].pts.push(h); bins[bi].pts_low.push(l); } @@ -743,9 +848,32 @@ pub fn tessellate( // 2. Process fill triangles for &[x, y] in &group.fill_tris { - let xv = x as f64 * anno + slx_v; - let yv = y as f64 * anno + sly_v; - let (h, l) = split_ds_xyz(xv, yv, elev_v); + let world = if let Some(plane) = group.plane { + let scaled_origin = [ + plane.scale_origin[0] + + (plane.origin[0] - plane.scale_origin[0]) * anno, + plane.scale_origin[1] + + (plane.origin[1] - plane.scale_origin[1]) * anno, + plane.scale_origin[2] + + (plane.origin[2] - plane.scale_origin[2]) * anno, + ]; + let x = x as f64 * anno; + let y = y as f64 * anno; + [ + scaled_origin[0] + + plane.x_axis[0] * x + + plane.y_axis[0] * y, + scaled_origin[1] + + plane.x_axis[1] * x + + plane.y_axis[1] * y, + scaled_origin[2] + + plane.x_axis[2] * x + + plane.y_axis[2] * y, + ] + } else { + [x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v] + }; + let (h, l) = split_ds_xyz(world[0], world[1], world[2]); bins[bi].fill_tris.push(h); bins[bi].fill_tris_low.push(l); } @@ -793,14 +921,35 @@ pub fn tessellate( run.bold, &run.text, ); - crate::scene::pipeline::text_gpu::push_glyph_vertices( - &mut sdf_verts, - &quads, - [slx_v, sly_v, elev_v], - anno, - gcolor, - 0.0, - ); + if let Some(plane) = group.plane { + let scaled_origin = [ + plane.scale_origin[0] + + (plane.origin[0] - plane.scale_origin[0]) * anno, + plane.scale_origin[1] + + (plane.origin[1] - plane.scale_origin[1]) * anno, + plane.scale_origin[2] + + (plane.origin[2] - plane.scale_origin[2]) * anno, + ]; + crate::scene::pipeline::text_gpu::push_glyph_vertices_on_plane( + &mut sdf_verts, + &quads, + scaled_origin, + plane.x_axis, + plane.y_axis, + anno, + gcolor, + 0.0, + ); + } else { + crate::scene::pipeline::text_gpu::push_glyph_vertices( + &mut sdf_verts, + &quads, + [slx_v, sly_v, elev_v], + anno, + gcolor, + 0.0, + ); + } } } } @@ -849,11 +998,12 @@ pub fn tessellate( .unwrap_or(m.rotation); let text_height = m.height * anno; let pad = (m.background_scale - 1.0).max(0.0) * text_height; - let corners = oriented_text_corners( + let corner_groups = oriented_mtext_corner_groups( &sdf_verts, - [m.insertion_point.x, m.insertion_point.y], + m, text_rotation, pad, + anno, ); // Fill / mask — two triangles behind the glyphs. if has_fill { @@ -862,13 +1012,18 @@ pub fn tessellate( } else { color_or_inherit(&m.background_color, bg_color) }; - let mut ft = Vec::with_capacity(6); - let mut ftl = Vec::with_capacity(6); - for &k in &[0usize, 1, 2, 0, 2, 3] { - let (h, lo) = - split_ds_xyz(corners[k][0], corners[k][1], elev_v); - ft.push(h); - ftl.push(lo); + let mut ft = Vec::with_capacity(6 * corner_groups.len()); + let mut ftl = Vec::with_capacity(6 * corner_groups.len()); + for corners in &corner_groups { + for &k in &[0usize, 1, 2, 0, 2, 3] { + let (h, lo) = split_ds_xyz( + corners[k][0], + corners[k][1], + elev_v, + ); + ft.push(h); + ftl.push(lo); + } } wires.push(WireModel { point_marker: None, @@ -907,19 +1062,24 @@ pub fn tessellate( // Text frame — a closed rectangle in the text // colour around the same box. if has_frame { - let loop_xy = [ - corners[0], - corners[1], - corners[2], - corners[3], - corners[0], - ]; - let mut fp = Vec::with_capacity(5); - let mut fpl = Vec::with_capacity(5); - for &[x, y] in &loop_xy { - let (h, lo) = split_ds_xyz(x, y, elev_v); - fp.push(h); - fpl.push(lo); + let mut fp = Vec::with_capacity(6 * corner_groups.len()); + let mut fpl = Vec::with_capacity(6 * corner_groups.len()); + for (group_index, corners) in corner_groups.iter().enumerate() { + if group_index > 0 { + fp.push([f32::NAN; 3]); + fpl.push([0.0; 3]); + } + for &[x, y] in &[ + corners[0], + corners[1], + corners[2], + corners[3], + corners[0], + ] { + let (h, lo) = split_ds_xyz(x, y, elev_v); + fp.push(h); + fpl.push(lo); + } } wires.push(WireModel { point_marker: None, @@ -1718,24 +1878,9 @@ pub fn tessellate( // f64 for the WireModel's double-single-era snap buffer. let snap_pts: Vec<(glam::DVec3, SnapHint)> = snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect(); - // A paper-space viewport is a window, not a wireframe: give it an interior - // pick surface so a click anywhere inside the frame selects it. Ranked - // below edge and fill hits, so content drawn inside still wins the click. - // The sheet ("overall") viewport is the layout's own invisible camera - // frame covering the whole page — never pickable, or it would swallow - // every click over the real viewports beneath it. It is identified by the - // Layout object's viewport link (authoritative — DWG files carry id = 0 - // and this file class centres the sheet viewport off-origin, so neither - // the id nor the geometry heuristic alone is reliable), with - // `is_content_viewport` as the fallback classifier. - let is_sheet_vp = |vp: &acadrust::entities::Viewport| { - let h = vp.common.handle; - document.objects.values().any(|obj| { - matches!(obj, acadrust::objects::ObjectType::Layout(l) if l.viewport == h) - }) || !crate::scene::Scene::is_content_viewport(vp) - }; + // Paper viewports are pickable inside their frames; the sheet viewport is not. let (pick_tris, pick_tris_low) = match entity { - EntityType::Viewport(vp) if !is_sheet_vp(vp) => { + EntityType::Viewport(vp) if !crate::scene::Scene::is_sheet_viewport(document, vp) => { if let Some(polygon) = clipped_viewport_polygon.as_ref() { points_to_ds(crate::entities::mesh::triangulate_planar(polygon)) } else { diff --git a/src/scene/creation_style.rs b/src/scene/creation_style.rs index ed4a1fc5..bfc4ac5c 100644 --- a/src/scene/creation_style.rs +++ b/src/scene/creation_style.rs @@ -134,10 +134,6 @@ fn apply_text_defaults(doc: &CadDocument, entity: &mut EntityType) { if text.oblique_angle.abs() <= 1.0e-9 { text.oblique_angle = resolved.oblique_angle; } - if text.generation_flags == 0 { - text.generation_flags = (if resolved.flags.backward { 2 } else { 0 }) - | (if resolved.flags.upside_down { 4 } else { 0 }); - } } EntityType::MText(text) => { if resolved.height > 1.0e-9 { diff --git a/src/scene/group_layer.rs b/src/scene/group_layer.rs index af957834..2c3aa78e 100644 --- a/src/scene/group_layer.rs +++ b/src/scene/group_layer.rs @@ -203,8 +203,11 @@ impl Scene { /// If any handle belongs to a selectable group, also select every member. pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) { + let previous_len = self.selected.len(); self.selected .extend(self.handles_expanded_for_selectable_groups(handles)); - self.bump_selection(); + if self.selected.len() != previous_len { + self.bump_selection_set(); + } } } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 313058bc..0894fe12 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1486,6 +1486,9 @@ pub struct Scene { /// pick only refreshes the GPU xray overlay (cheap) instead of bumping /// `geometry_epoch` and re-tessellating the whole model. pub selection_generation: u64, + /// Cached fingerprint of `selected`, recomputed lazily after mutations. + selection_fingerprint_cache: u64, + selection_fingerprint_dirty: bool, /// Cached tessellation of all visible entity wires for the current layout. /// Keyed by `(geometry_epoch, camera_generation)` so a camera change /// invalidates the cull-dependent wire list as well as a geometry change. @@ -1865,6 +1868,8 @@ impl Scene { projection_bounds_epoch: std::cell::Cell::new(0), block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed), selection_generation: 0, + selection_fingerprint_cache: 0, + selection_fingerprint_dirty: false, wire_cache: RefCell::new(None), interaction_index_cache: RefCell::new(Vec::new()), interaction_index_pending_key: std::cell::Cell::new(None), @@ -2618,6 +2623,11 @@ impl Scene { self.selection_generation = self.selection_generation.wrapping_add(1); } + pub(crate) fn bump_selection_set(&mut self) { + self.selection_fingerprint_dirty = true; + self.bump_selection(); + } + /// Milliseconds after the last camera change during which the view counts as /// "actively navigating" for interaction-LOD purposes. const NAV_SETTLE_MS: u128 = 130; @@ -2998,32 +3008,96 @@ impl Scene { crate::io::set_saved_active_layout(&mut self.document, &self.current_layout); } - /// Returns true if this viewport should display model-space content - /// (i.e. it is a user viewport, not the sheet/overall viewport). - /// - /// Rules: - /// - id=1 → always the sheet viewport → false - /// - id≥2 → always a user viewport → true - /// - id=0 or id<0 (DWG reader omits the id; some DXF exporters write -1): - /// use geometry: the sheet viewport is centred at the paper origin (0,0) - /// with scale≈1.0 (view_height ≈ paper-space height). - pub fn is_content_viewport(vp: &acadrust::entities::Viewport) -> bool { - if vp.id == 1 { - return false; + pub(crate) fn layout_sheet_viewport_handle( + document: &acadrust::CadDocument, + layout: &acadrust::objects::Layout, + ) -> Handle { + let owned_viewport = |handle| match document.get_entity(handle) { + Some(EntityType::Viewport(vp)) if vp.common.owner_handle == layout.block_record => { + Some(vp) + } + _ => None, + }; + + let listed = layout + .viewports + .iter() + .copied() + .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); + if let Some((handle, _)) = listed.clone().find(|(_, vp)| vp.id == 1) { + return handle; } - if vp.id > 1 { + if let Some((handle, _)) = listed.into_iter().next() { + return handle; + } + + let block_handles = document + .block_records + .iter() + .find(|block| block.handle == layout.block_record) + .map(|block| block.entity_handles.as_slice()) + .unwrap_or_default(); + let block_viewports = block_handles + .iter() + .copied() + .filter_map(|handle| owned_viewport(handle).map(|vp| (handle, vp))); + if let Some((handle, _)) = block_viewports.clone().find(|(_, vp)| vp.id == 1) { + return handle; + } + if let Some((handle, _)) = block_viewports.into_iter().next() { + return handle; + } + + document + .entities() + .filter_map(|entity| match entity { + EntityType::Viewport(vp) if vp.common.owner_handle == layout.block_record => { + Some(( + vp.common.handle, + vp.id == 1, + vp.width.abs() * vp.height.abs(), + )) + } + _ => None, + }) + .max_by(|a, b| a.1.cmp(&b.1).then_with(|| a.2.total_cmp(&b.2))) + .map(|(handle, _, _)| handle) + .unwrap_or(Handle::NULL) + } + + pub(crate) fn is_sheet_viewport( + document: &acadrust::CadDocument, + vp: &acadrust::entities::Viewport, + ) -> bool { + if vp.id == 1 { return true; } - // id ≤ 0: DWG files never write group-code 69 (viewport id), so all - // viewports arrive with id=0. - // - // In DWG format the sheet ("overall") viewport always has its center at - // the paper-space origin (0, 0). Content viewports are placed at their - // actual position on the paper and therefore have a non-zero center. - // Using center position is more reliable than a scale heuristic because - // the sheet viewport's scale is not always exactly 1:1 (observed: 0.8965 - // in real-world files, which the old 0.02 tolerance missed entirely). - vp.center.x.abs() >= 0.5 || vp.center.y.abs() >= 0.5 + if vp.id > 1 { + return false; + } + document.objects.values().any(|object| { + matches!( + object, + ObjectType::Layout(layout) + if layout.block_record == vp.common.owner_handle + && Self::layout_sheet_viewport_handle(document, layout) + == vp.common.handle + ) + }) + } + + pub(crate) fn sheet_viewport_handles(&self) -> std::collections::HashSet { + self.document + .objects + .values() + .filter_map(|object| match object { + ObjectType::Layout(layout) => { + let handle = Self::layout_sheet_viewport_handle(&self.document, layout); + handle.is_valid().then_some(handle) + } + _ => None, + }) + .collect() } fn current_layout_sheet_viewport_handle(&self) -> Handle { @@ -3035,7 +3109,7 @@ impl Scene { return None; }; if layout.name == self.current_layout { - Some(layout.viewport) + Some(Self::layout_sheet_viewport_handle(&self.document, layout)) } else { None } @@ -3043,82 +3117,49 @@ impl Scene { .unwrap_or(Handle::NULL) } - /// Guarantee that a paper layout has its full-screen overall (`id == 1`) - /// sheet viewport. `add_layout` creates it; this is a safety net for layouts - /// that arrive without it. The sheet - /// viewport is the authoritative paper-space view and the canvas every - /// floating viewport overlays. + /// Ensure a paper layout has its overall (`id == 1`) viewport. pub fn ensure_sheet_viewport(&mut self, layout_name: &str) { if layout_name == "Model" { return; } - // Locate the layout: its object handle, block-record handle, current - // sheet-viewport link, and paper limits. + // Locate the layout and its paper limits. let info = self.document.objects.iter().find_map(|(h, obj)| { if let ObjectType::Layout(l) = obj { if l.name == layout_name { - return Some((*h, l.block_record, l.viewport, l.min_limits, l.max_limits)); + return Some(( + *h, + l.block_record, + Self::layout_sheet_viewport_handle(&self.document, l), + l.min_limits, + l.max_limits, + )); } } None }); - let Some((layout_handle, block_record, cur_vp, min_lim, max_lim)) = info else { + let Some((layout_handle, block_record, sheet, min_lim, max_lim)) = info else { return; }; if block_record.is_null() { return; } - // Normal files carry a valid direct Layout→Viewport link. This O(1) - // path is hit on every ordinary layout-tab switch. - if cur_vp.is_valid() + if sheet.is_valid() && matches!( - self.document.get_entity(cur_vp), + self.document.get_entity(sheet), Some(EntityType::Viewport(vp)) if vp.common.owner_handle == block_record ) { return; } - // Already present? Accept either the linked viewport handle or any - // `id == 1` viewport owned by the layout block. - let has_sheet = self.document.entities().any(|e| { - matches!(e, EntityType::Viewport(vp) - if vp.common.owner_handle == block_record - && (vp.id == 1 || vp.common.handle == cur_vp)) - }); - if has_sheet { - // Keep the layout's link in sync if it was missing. - if !cur_vp.is_valid() { - let h = self.document.entities().find_map(|e| match e { - EntityType::Viewport(vp) - if vp.common.owner_handle == block_record && vp.id == 1 => - { - Some(vp.common.handle) - } - _ => None, - }); - if let Some(h) = h { - if let Some(ObjectType::Layout(l)) = - self.document.objects.get_mut(&layout_handle) - { - l.viewport = h; - } - } - } - return; - } - // Create the full-screen overall viewport covering the paper limits. let pw = (max_lim.0 - min_lim.0).abs().max(1.0); let ph = (max_lim.1 - min_lim.1).abs().max(1.0); let mut vp = acadrust::entities::Viewport::new(); vp.id = 1; vp.status = acadrust::entities::ViewportStatusFlags::default_on(); - // Paper-space center is a 2D (x, y) point with z = 0. Putting the - // paper-height midpoint in z - // (with y = 0) left the sheet view centered at y = 0, shifting the whole - // layout half a page down. See issue #156. + // Paper-space center is an (x, y) point with z = 0. vp.center = acadrust::types::Vector3::new( (min_lim.0 + max_lim.0) / 2.0, (min_lim.1 + max_lim.1) / 2.0, @@ -3126,12 +3167,7 @@ impl Scene { ); vp.width = pw; vp.height = ph; - // Frame the new layout on the whole sheet: look straight down at the - // paper centre with the visible height a touch taller than the page. - // Without this the viewport keeps `Viewport::new`'s default view - // (target 0,0 / height 210), so the first time a fresh drawing's - // layout is opened the camera sits on the paper's bottom-left corner - // instead of centring the sheet. + // Frame the full sheet with a small margin. vp.view_target = acadrust::types::Vector3::new( (min_lim.0 + max_lim.0) / 2.0, (min_lim.1 + max_lim.1) / 2.0, @@ -3144,7 +3180,8 @@ impl Scene { .add_entity_to_layout(EntityType::Viewport(vp), layout_name) { if let Some(ObjectType::Layout(l)) = self.document.objects.get_mut(&layout_handle) { - l.viewport = handle; + l.viewports.retain(|candidate| *candidate != handle); + l.viewports.insert(0, handle); } } } @@ -3161,7 +3198,7 @@ impl Scene { if sheet_handle.is_valid() { vp.common.handle != sheet_handle } else { - Self::is_content_viewport(vp) + !Self::is_sheet_viewport(&self.document, vp) } } @@ -4555,6 +4592,7 @@ impl Scene { .extend(self.selected.iter().copied()); self.selected.clear(); self.selected_order.clear(); + self.bump_selection_set(); self.bump_entities(&changes); } diff --git a/src/scene/paper.rs b/src/scene/paper.rs index db2a4e76..d7a1d928 100644 --- a/src/scene/paper.rs +++ b/src/scene/paper.rs @@ -29,7 +29,7 @@ impl Scene { && if sheet.is_valid() { handle != sheet } else { - Self::is_content_viewport(vp) + !Self::is_sheet_viewport(&self.document, vp) } }; let content = if let Some(block) = self diff --git a/src/scene/pipeline/text_gpu.rs b/src/scene/pipeline/text_gpu.rs index 8bfc3bee..14d2b3b5 100644 --- a/src/scene/pipeline/text_gpu.rs +++ b/src/scene/pipeline/text_gpu.rs @@ -151,6 +151,51 @@ pub fn push_glyph_vertices( } } +#[allow(clippy::too_many_arguments)] +pub fn push_glyph_vertices_on_plane( + out: &mut Vec, + quads: &[GlyphQuad], + origin: [f64; 3], + x_axis: [f64; 3], + y_axis: [f64; 3], + anno: f64, + color: [f32; 4], + draw_depth: f32, +) { + for q in quads { + let mk = |ci: usize, uv: [f32; 2]| -> TextVertex { + let c = q.corners[ci]; + let x = c[0] as f64 * anno; + let y = c[1] as f64 * anno; + let world = [ + origin[0] + x_axis[0] * x + y_axis[0] * y, + origin[1] + x_axis[1] * x + y_axis[1] * y, + origin[2] + x_axis[2] * x + y_axis[2] * y, + ]; + let (xh, xl) = split_ds(world[0]); + let (yh, yl) = split_ds(world[1]); + let (zh, zl) = split_ds(world[2]); + TextVertex { + pos: [xh, yh, zh], + pos_low: [xl, yl, zl], + uv, + color, + draw_depth, + } + }; + let bl = [q.uv_min[0], q.uv_max[1]]; + let br = [q.uv_max[0], q.uv_max[1]]; + let tr = [q.uv_max[0], q.uv_min[1]]; + let tl = [q.uv_min[0], q.uv_min[1]]; + out.push(mk(0, bl)); + out.push(mk(1, br)); + out.push(mk(2, tr)); + out.push(mk(0, bl)); + out.push(mk(2, tr)); + out.push(mk(3, tl)); + } +} + /// Slide every glyph vertex by a world-space delta, re-splitting the /// double-single position. Lets a grip drag move already-shaped text by /// translating the drag-start glyphs each frame instead of re-tessellating diff --git a/src/scene/selection.rs b/src/scene/selection.rs index af107d9c..aa951bdd 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1,6 +1,9 @@ // Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. @@ -37,8 +40,11 @@ impl Scene { } pub fn select_entity(&mut self, handle: Handle, exclusive: bool) { let handles = self.handles_expanded_for_leader_annotations(&[handle]); + let mut changed = false; if exclusive { + changed = self.selected.len() != handles.len() + || handles.iter().any(|handle| !self.selected.contains(handle)); self.selected.clear(); self.selected_order.clear(); } @@ -46,15 +52,35 @@ impl Scene { for handle in handles { if self.selected.insert(handle) { self.selected_order.push(handle); + changed = true; } } - self.bump_selection(); + if changed { + self.bump_selection_set(); + } } pub fn deselect_all(&mut self) { + if self.selected.is_empty() { + return; + } self.selected.clear(); self.selected_order.clear(); - self.bump_selection(); + self.bump_selection_set(); + } + + pub(crate) fn selection_fingerprint(&mut self) -> u64 { + if self.selection_fingerprint_dirty { + let mut fingerprint = self.selected.len() as u64; + for handle in &self.selected { + let mut hasher = DefaultHasher::new(); + handle.hash(&mut hasher); + fingerprint ^= hasher.finish(); + } + self.selection_fingerprint_cache = fingerprint; + self.selection_fingerprint_dirty = false; + } + self.selection_fingerprint_cache } pub(crate) fn selected_handles_in_order(&self) -> Vec { @@ -103,7 +129,7 @@ impl Scene { order.extend(added); self.selected = selected; self.selected_order = order; - self.bump_selection(); + self.bump_selection_set(); } } @@ -118,7 +144,7 @@ impl Scene { } if changed { - self.bump_selection(); + self.bump_selection_set(); } } @@ -199,7 +225,7 @@ impl Scene { } } if added > 0 { - self.bump_selection(); + self.bump_selection_set(); } added } @@ -223,7 +249,9 @@ impl Scene { self.selected_order.push(h); } } - self.bump_selection(); + if self.selected != prev { + self.bump_selection_set(); + } self.selected.len() } @@ -709,7 +737,8 @@ impl Scene { let mut handle_set: HashSet = HashSet::default(); let mut erased: Vec<(Handle, ChangeKind)> = Vec::new(); - let mut highlight_changed = false; + let mut selection_changed = false; + let mut hover_changed = false; for &h in &erase_handles { // Objects on a locked layer can't be erased. @@ -724,11 +753,11 @@ impl Scene { self.delete_solid_history(h); self.remember_removed_cache_categories(h); self.document.remove_entity_arc(h); - highlight_changed |= self.selected.remove(&h); + selection_changed |= self.selected.remove(&h); self.selected_order.retain(|selected| *selected != h); if self.hover_highlight == Some(h) { self.hover_highlight = None; - highlight_changed = true; + hover_changed = true; } self.hatches.remove(&h); self.images.remove(&h); @@ -738,7 +767,9 @@ impl Scene { handle_set.insert(h); erased.push((h, ChangeKind::Removed)); } - if highlight_changed { + if selection_changed { + self.bump_selection_set(); + } else if hover_changed { self.bump_selection(); } // Capture exactly the group objects that this erase will rewrite, plus @@ -838,3 +869,29 @@ impl Scene { restored } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_fingerprint_tracks_final_set_only() { + let mut scene = Scene::default(); + let first = Handle::new(1); + let second = Handle::new(2); + scene.select_entity(first, false); + scene.select_entity(second, false); + let fingerprint = scene.selection_fingerprint(); + assert!(!scene.selection_fingerprint_dirty); + + scene.deselect_all(); + scene.select_entity(second, false); + scene.select_entity(first, false); + assert!(scene.selection_fingerprint_dirty); + assert_eq!(scene.selection_fingerprint(), fingerprint); + + scene.set_hover_highlight(Some(Handle::new(3))); + assert!(!scene.selection_fingerprint_dirty); + assert_eq!(scene.selection_fingerprint(), fingerprint); + } +} diff --git a/src/ui/window/geometric_tolerance.rs b/src/ui/window/geometric_tolerance.rs index 214f4abd..d3e93c15 100644 --- a/src/ui/window/geometric_tolerance.rs +++ b/src/ui/window/geometric_tolerance.rs @@ -38,6 +38,11 @@ pub struct State { pub projected_height: String, pub projected_zone: bool, pub datum_identifier: String, + plain_frame: bool, + symbol_tail: String, + extra_rows: Vec, + original_text: Option, + dirty: bool, } impl Default for State { @@ -50,6 +55,11 @@ impl Default for State { projected_height: String::new(), projected_zone: false, datum_identifier: String::new(), + plain_frame: false, + symbol_tail: String::new(), + extra_rows: Vec::new(), + original_text: None, + dirty: false, } } } @@ -130,16 +140,32 @@ fn escape(code: &str) -> String { } } -fn strip_escape(input: &str, code: &str) -> Option { - let marker = escape(code); - input - .strip_suffix(&marker) - .map(std::string::ToString::to_string) +fn remove_escape(input: &str, code: &str) -> Option { + let target = code.chars().next()?; + for (start, ch) in input.char_indices() { + if ch != '{' { + continue; + } + let tail = &input[start + 1..]; + let Some(relative_end) = tail.find('}') else { + break; + }; + let end = start + 1 + relative_end; + if crate::entities::tolerance::symbol_font_switch(&input[start + 1..end]) + == Some(target) + { + let mut remaining = String::with_capacity(input.len() - (end + 1 - start)); + remaining.push_str(&input[..start]); + remaining.push_str(&input[end + 1..]); + return Some(remaining); + } + } + None } fn parse_material(input: &str) -> (String, String) { for code in ["m", "l", "s"] { - if let Some(value) = strip_escape(input, code) { + if let Some(value) = remove_escape(input, code) { return (value, code.to_string()); } } @@ -157,125 +183,186 @@ impl State { .replace("\\P", "\n") .replace("\r\n", "\n") .replace('\r', "\n"); - let mut lines = normalized.lines(); + let lines: Vec<&str> = normalized.split('\n').collect(); + state.original_text = (!raw.is_empty()).then(|| raw.to_string()); - if let Some(frame) = lines.next() { + if let Some(frame) = lines.first().copied() { let cells: Vec<&str> = frame.split("%%v").collect(); if let Some(cell) = cells.first() { - state.symbol = SYMBOLS - .iter() - .find_map(|(code, _)| { - (!code.is_empty() && cell.contains(&escape(code))).then(|| code.to_string()) - }) - .unwrap_or_default(); - } - for index in 0..2 { - if let Some(cell) = cells.get(index + 1) { - let (cell, diameter) = if let Some(rest) = cell.strip_prefix(&escape("n")) { - (rest, true) - } else { - (*cell, false) - }; - let (value, material) = parse_material(cell); - state.tolerances[index] = ToleranceEntry { - diameter, - value, - material, - }; + let mut remaining = (*cell).to_string(); + for (code, _) in SYMBOLS.iter().filter(|(code, _)| !code.is_empty()) { + if let Some(value) = remove_escape(&remaining, code) { + state.symbol = (*code).to_string(); + remaining = value; + break; + } } + state.symbol_tail = remaining; } - for index in 0..3 { - if let Some(cell) = cells.get(index + 3) { - let (value, material) = parse_material(cell); - state.datums[index] = DatumEntry { value, material }; + state.plain_frame = !raw.is_empty() && cells.len() == 1 && state.symbol.is_empty(); + if state.plain_frame { + state.tolerances[0].value = frame.to_string(); + state.symbol_tail.clear(); + } else { + for index in 0..2 { + if let Some(cell) = cells.get(index + 1) { + let (cell, diameter) = remove_escape(cell, "n") + .map_or_else(|| ((*cell).to_string(), false), |value| (value, true)); + let (value, material) = parse_material(&cell); + state.tolerances[index] = ToleranceEntry { + diameter, + value, + material, + }; + } + } + for index in 0..3 { + if let Some(cell) = cells.get(index + 3) { + let (value, material) = parse_material(cell); + state.datums[index] = DatumEntry { value, material }; + } } - } - if cells.len() == 1 - && state.symbol.is_empty() - && !frame.trim().is_empty() - { - state.tolerances[0].value = frame.trim().to_string(); } } - if let Some(projected) = lines.next() { - if let Some(value) = strip_escape(projected, "p") { + let mut next = 1; + if let Some(projected) = lines.get(next).copied() { + if let Some(value) = remove_escape(projected, "p") { state.projected_height = value; state.projected_zone = true; - } else { + next += 1; + } else if lines.get(next + 1).is_some() + && !projected.contains("%%v") + && !lines[next + 1].contains("%%v") + { state.projected_height = projected.to_string(); + next += 1; } } - if let Some(identifier) = lines.next() { - state.datum_identifier = identifier.to_string(); + if next > 1 { + if let Some(identifier) = lines.get(next).copied() { + if !identifier.contains("%%v") { + state.datum_identifier = identifier.to_string(); + next += 1; + } + } } + state.extra_rows = lines[next..].iter().map(|row| (*row).to_string()).collect(); state } pub fn apply_field(&mut self, field: Field) { - match field { - Field::Symbol(value) => self.symbol = value, + let changed = match field { + Field::Symbol(value) => { + let changed = self.symbol != value; + self.symbol = value; + changed + } Field::ToleranceValue(index, value) if index < 2 => { - self.tolerances[index].value = value + let changed = self.tolerances[index].value != value; + self.tolerances[index].value = value; + changed } Field::ToleranceMaterial(index, value) if index < 2 => { - self.tolerances[index].material = value + let changed = self.tolerances[index].material != value; + self.tolerances[index].material = value; + changed + } + Field::DatumValue(index, value) if index < 3 => { + let changed = self.datums[index].value != value; + self.datums[index].value = value; + changed } - Field::DatumValue(index, value) if index < 3 => self.datums[index].value = value, Field::DatumMaterial(index, value) if index < 3 => { - self.datums[index].material = value + let changed = self.datums[index].material != value; + self.datums[index].material = value; + changed } - Field::ProjectedHeight(value) => self.projected_height = value, - Field::DatumIdentifier(value) => self.datum_identifier = value, - _ => {} - } + Field::ProjectedHeight(value) => { + let changed = self.projected_height != value; + self.projected_height = value; + changed + } + Field::DatumIdentifier(value) => { + let changed = self.datum_identifier != value; + self.datum_identifier = value; + changed + } + _ => false, + }; + self.dirty |= changed; } pub fn apply_toggle(&mut self, toggle: Toggle) { - match toggle { + let changed = match toggle { Toggle::Diameter(index, value) if index < 2 => { - self.tolerances[index].diameter = value + let changed = self.tolerances[index].diameter != value; + self.tolerances[index].diameter = value; + changed } - Toggle::ProjectedZone(value) => self.projected_zone = value, - _ => {} - } + Toggle::ProjectedZone(value) => { + let changed = self.projected_zone != value; + self.projected_zone = value; + changed + } + _ => false, + }; + self.dirty |= changed; } pub fn is_valid(&self) -> bool { !self.symbol.is_empty() + || !self.symbol_tail.is_empty() || self.tolerances.iter().any(|entry| !entry.value.trim().is_empty()) || self.datums.iter().any(|entry| !entry.value.trim().is_empty()) || !self.projected_height.trim().is_empty() || !self.datum_identifier.trim().is_empty() + || self.extra_rows.iter().any(|row| !row.trim().is_empty()) } pub fn to_text(&self) -> String { - let mut cells = Vec::with_capacity(6); - cells.push(escape(&self.symbol)); - for entry in &self.tolerances { - let mut value = String::new(); - if entry.diameter && !entry.value.trim().is_empty() { - value.push_str(&escape("n")); + if !self.dirty { + if let Some(original) = &self.original_text { + return original.clone(); } - value.push_str(entry.value.trim()); - if !entry.value.trim().is_empty() { - value.push_str(&escape(&entry.material)); + } + + let only_plain_value = self.plain_frame + && self.symbol.is_empty() + && self.symbol_tail.is_empty() + && self.tolerances[1] == ToleranceEntry::default() + && self.datums.iter().all(|entry| *entry == DatumEntry::default()); + let frame = if only_plain_value { + self.tolerances[0].value.clone() + } else { + let mut cells = Vec::with_capacity(6); + cells.push(format!("{}{}", escape(&self.symbol), self.symbol_tail)); + for entry in &self.tolerances { + let mut value = String::new(); + if entry.diameter && !entry.value.trim().is_empty() { + value.push_str(&escape("n")); + } + value.push_str(&entry.value); + if !entry.value.trim().is_empty() { + value.push_str(&escape(&entry.material)); + } + cells.push(value); } - cells.push(value); - } - for entry in &self.datums { - let mut value = entry.value.trim().to_string(); - if !value.is_empty() { - value.push_str(&escape(&entry.material)); + for entry in &self.datums { + let mut value = entry.value.clone(); + if !value.trim().is_empty() { + value.push_str(&escape(&entry.material)); + } + cells.push(value); } - cells.push(value); - } - while cells.last().is_some_and(String::is_empty) { - cells.pop(); - } - let mut rows = vec![cells.join("%%v")]; + while cells.last().is_some_and(String::is_empty) { + cells.pop(); + } + cells.join("%%v") + }; + let mut rows = vec![frame]; if !self.projected_height.trim().is_empty() || self.projected_zone { - let mut projected = self.projected_height.trim().to_string(); + let mut projected = self.projected_height.clone(); if self.projected_zone { projected.push_str(&escape("p")); } @@ -285,8 +372,9 @@ impl State { if rows.len() == 1 { rows.push(String::new()); } - rows.push(self.datum_identifier.trim().to_string()); + rows.push(self.datum_identifier.clone()); } + rows.extend(self.extra_rows.iter().cloned()); rows.join("\n") } } From 513ab958a89eca1860eb4dac63cf9418407b1a88 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Wed, 26 Aug 2026 23:51:37 +0100 Subject: [PATCH 98/98] perf(grid): cache overlay geometry behind a params-keyed key Introduce a cache for the grid overlay to prevent redundant geometry reconstruction on every UI redraw. Extract the pure projection logic from draw_grid into a renderer-free grid_segments function, and add GridKey and GridCanvasState to memoize the resulting Geometry. The cache key includes all per-pane GridParams and the overlay bounds, preventing stale geometry when camera changes within the same canvas size. On a params-key miss, the iced cache is cleared before redrawing to bypass its bounds-only reuse. Tests: 8 new unit tests cover key invalidation and state handling. Benchmarks: uncached geometry ~32 us/frame vs cached decision ~90 ns, a >99% reduction. Verified: 472 passed / 1 pre-existing failure / 4 ignored. Tests: ui::overlay::grid_key_tests::grid_key_matches_identical_params ui::overlay::grid_key_tests::grid_key_invalidates_on_changed_fields ui::overlay::grid_key_tests::grid_key_invalidates_when_any_pane_changes ui::overlay::grid_key_tests::should_reuse_empty ui::overlay::grid_key_tests::should_reuse_equal ui::overlay::grid_key_tests::should_reuse_changed ui::overlay::grid_canvas_state_tests::default_state_has_no_cached_key ui::overlay::grid_canvas_state_tests::stored_key_is_recognized_by_should_reuse --- src/ui/overlay.rs | 653 ++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 579 insertions(+), 74 deletions(-) diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 22766f4e..1e315405 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1,5 +1,7 @@ //! Viewport overlay widgets. +use std::cell::RefCell; + use glam::{Mat4, Vec3}; use iced::mouse; use iced::widget::canvas; @@ -88,7 +90,7 @@ pub struct GripMarker { // ── Grid display params ─────────────────────────────────────────────────── /// Passed to the canvas when the GRID display is active. -#[derive(Clone)] +#[derive(Clone, Copy, Debug, PartialEq)] pub struct GridParams { /// Rotation-only view-projection (Camera::view_proj_rte). Grid points are /// made relative to `eye` in f64 before projecting, so the grid stays @@ -110,6 +112,77 @@ pub struct GridParams { pub limits: Option<(glam::DVec2, glam::DVec2)>, } +/// Pure result of grid projection — segments in canvas-local coordinates plus +/// the axis extent (in world units along the active UCS axes) that the wrapper +/// uses to size the coloured UCS axes overlay. Returned from `grid_segments` so +/// the renderer-free geometry construction can be unit-tested and benchmarked +/// without an iced `Renderer` (Mission #1, 2026-08-26 bench-first plan). +pub(crate) struct GridGeometry { + pub segments: Vec<(Point, Point)>, + pub axis_extent: f32, +} + +impl GridGeometry { + /// Empty geometry — no segments drawn, axes suppressed. Returned by the + /// early-exit branches of `grid_segments` (zero-sized bounds, no visible + /// samples, non-finite step) so the caller never needs to special-case + /// the `None` path. + fn empty() -> Self { + Self { segments: Vec::new(), axis_extent: 0.0 } + } +} + +/// Cache key for the grid overlay. Identical `GridParams` for every pane plus +/// identical overlay `bounds` ⇒ byte-identical grid geometry; that is the +/// invariant the key encodes. +/// +/// Bounds are part of the key, not the only key: a tile layout can pan/zoom +/// inside a single bounds rect, so bounds-only reuse (iced's +/// `geometry::Cache`) would serve a stale grid. The full per-pane +/// `GridParams` set is required for correctness. +/// +/// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1). +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct GridKey { + pub grids: Vec, + pub bounds: iced::Rectangle, +} + +impl GridKey { + /// Build a key from the per-pane `GridParams` and the overlay bounds. The + /// input slice is copied; callers can drop the original. + pub(crate) fn from_grids(grids: &[GridParams], bounds: iced::Rectangle) -> Self { + Self { grids: grids.to_vec(), bounds } + } +} + +/// Cache hit decision: `true` iff `cached` is `Some` and structurally equal to +/// `new`. Reference-based to avoid moving the (potentially large) `Vec` of +/// per-pane params; the caller borrows from `RefCell>` on +/// both sides. +pub(crate) fn should_reuse(cached: Option<&GridKey>, new: &GridKey) -> bool { + match cached { + Some(old) => old == new, + None => false, + } +} + +/// `Program::State` for `GridCanvas`. Stores the `GridKey` of the geometry +/// currently in the cache and the `canvas::Cache` itself. +/// +/// `RefCell>` because `Program::draw` takes `&self`; the key +/// is updated on every frame regardless of hit/miss. The `canvas::Cache` +/// provides an Arc-clone hit path when the iced-level bounds match, and we +/// use `clear()` on a params-key miss to force a real rebuild even if the +/// bounds happen to be unchanged (e.g. a pan within the same canvas size). +/// +/// Added 2026-08-26 by Mission #1 (grid overlay cache, Tier 1 #1). +#[derive(Default)] +pub(crate) struct GridCanvasState { + pub key: RefCell>, + pub cache: canvas::Cache, +} + /// Compute the adaptive grid step size (world units) from camera zoom. /// /// Returns the smallest power-of-5 multiple of 1.0 that places grid lines at @@ -219,39 +292,72 @@ struct GridCanvas { } impl canvas::Program for GridCanvas { - type State = (); + type State = GridCanvasState; fn draw( &self, - _state: &(), + state: &GridCanvasState, renderer: &iced::Renderer, _theme: &Theme, bounds: iced::Rectangle, _cursor: mouse::Cursor, ) -> Vec { - let mut frame = canvas::Frame::new(renderer, bounds.size()); + let key = GridKey::from_grids(&self.grid, bounds); - for g in &self.grid { - let gb = g.bounds; - let cx0 = gb.x.max(0.0); - let cy0 = gb.y.max(0.0); - let cx1 = (gb.x + gb.width).min(bounds.width); - let cy1 = (gb.y + gb.height).min(bounds.height); - if cx1 <= cx0 || cy1 <= cy0 { - continue; - } - let clip = iced::Rectangle { - x: cx0, - y: cy0, - width: cx1 - cx0, - height: cy1 - cy0, - }; - frame.with_clip(clip, |f| { - draw_grid(f, g.view_rot, g.eye, gb, g.step, g.origin, g.axes, g.limits) - }); - } + // Hit check: same params, same bounds ⇒ the cached geometry is still + // valid. The key includes bounds, so a `should_reuse` match implies + // both are equal, and the fork's `draw_with_bounds` will return the + // cached `Arc` clone (essentially free). + let hit = should_reuse(state.key.borrow().as_ref(), &key); - vec![frame.into_geometry()] + let geometry = if hit { + // No-op closure: the fork short-circuits on bounds match and + // returns the cached geometry without invoking the closure. + state.cache.draw_with_bounds(renderer, bounds, |_frame| {}) + } else { + // Params or bounds changed. Clear so the closure runs even when + // bounds happen to match the previously-cached frame (e.g. a pan + // within the same canvas size would otherwise leave stale + // geometry served — the fork's bounds-equality test would return + // the cached clone without calling our rebuild closure). + state.cache.clear(); + state.cache.draw_with_bounds(renderer, bounds, |frame| { + for g in &self.grid { + let gb = g.bounds; + let cx0 = gb.x.max(0.0); + let cy0 = gb.y.max(0.0); + let cx1 = (gb.x + gb.width).min(bounds.width); + let cy1 = (gb.y + gb.height).min(bounds.height); + if cx1 <= cx0 || cy1 <= cy0 { + continue; + } + let clip = iced::Rectangle { + x: cx0, + y: cy0, + width: cx1 - cx0, + height: cy1 - cy0, + }; + frame.with_clip(clip, |f| { + draw_grid( + f, + g.view_rot, + g.eye, + gb, + g.step, + g.origin, + g.axes, + g.limits, + ) + }); + } + }) + }; + + // Update the stored key. Safe: the `borrow()` for `should_reuse` is + // dropped at the end of the `if` expression above; no live Ref here. + *state.key.borrow_mut() = Some(key); + + vec![geometry] } } @@ -1244,8 +1350,53 @@ fn draw_grid( grid_axes: (Vec3, Vec3, Vec3), limits: Option<(glam::DVec2, glam::DVec2)>, ) { + let gc = Color { + r: 0.28, + g: 0.28, + b: 0.28, + a: 0.7, + }; + let st = canvas::Stroke { + width: 0.5, + style: canvas::Style::Solid(gc), + ..Default::default() + }; + let geometry = grid_segments(view_rot, eye, bounds, step, grid_origin, grid_axes, limits); + if !geometry.segments.is_empty() { + let path = canvas::Path::new(|builder| { + for (p0, p1) in &geometry.segments { + builder.move_to(*p0); + builder.line_to(*p1); + } + }); + frame.stroke(&path, st); + } + if geometry.axis_extent > 0.0 { + let (gx, gy, gz) = grid_axes; + let extent = (geometry.axis_extent + step) * 1.5; + draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz)); + } +} + +/// Pure, renderer-free projection of the grid for one pane. Returns canvas-local +/// `(Point, Point)` segments plus the axis extent (in world units along the +/// active UCS axes) used by `draw_grid` to size the coloured UCS axes overlay. +/// +/// Extracted from `draw_grid` (2026-08-26, Mission #1 step 1) so the geometry +/// construction can be unit-tested and benchmarked without an iced +/// `Renderer`. Behaviour is identical to the inlined version that preceded it. +#[allow(clippy::too_many_arguments)] +pub(crate) fn grid_segments( + view_rot: Mat4, + eye: glam::DVec3, + bounds: iced::Rectangle, + step: f32, + grid_origin: glam::DVec3, + grid_axes: (Vec3, Vec3, Vec3), + limits: Option<(glam::DVec2, glam::DVec2)>, +) -> GridGeometry { if bounds.width <= 0.0 || bounds.height <= 0.0 { - return; + return GridGeometry::empty(); } // World → viewport-local screen via relative-to-eye: subtract the f64 eye @@ -1371,28 +1522,16 @@ fn draw_grid( } } if samples.is_empty() { - return; + return GridGeometry::empty(); }; // Step follows camera zoom only. The previous visible-sample calculation // changed depth while orbiting and made the grid jump 1 → 5 → 25. if !step.is_finite() || step <= 0.0 { - return; + return GridGeometry::empty(); } let s = step; - let gc = Color { - r: 0.28, - g: 0.28, - b: 0.28, - a: 0.7, - }; - let st = canvas::Stroke { - width: 0.5, - style: canvas::Style::Solid(gc), - ..Default::default() - }; - // Trace a family-specific visible region around the viewport perimeter. // When a boundary ray points through the horizon, binary-search back toward // a readable anchor and stop where neighbouring lines reach the minimum gap. @@ -1625,18 +1764,9 @@ fn draw_grid( } result }; - let draw_segments = |frame: &mut canvas::Frame, segments: &[(Point, Point)]| { - if segments.is_empty() { - return; - } - let path = canvas::Path::new(|builder| { - for (p0, p1) in segments { - builder.move_to(*p0); - builder.line_to(*p1); - } - }); - frame.stroke(&path, st.clone()); - }; + + let mut all_segments: Vec<(Point, Point)> = Vec::new(); + let mut axis_extent = 0.0_f32; // A finite LIMITS rectangle replaces the usual viewport/horizon extent. // Clip each UCS grid line analytically against the WCS XY rectangle, then @@ -1722,34 +1852,23 @@ fn draw_grid( } } } - draw_segments(frame, &segments); + all_segments.extend(segments); // LIMITS bounds the grid, not the UCS axes. Size the axes from the // visible grid plane so X/Y/Z still span the viewport even when the // finite grid rectangle is small or currently off-screen. - let axis_extent = samples.iter().fold(0.0_f32, |extent, (_, world)| { + let limits_extent = samples.iter().fold(0.0_f32, |extent, (_, world)| { let delta = (*world - grid_origin).as_vec3(); extent .max(delta.dot(axis1).abs()) .max(delta.dot(axis2).abs()) }); - if axis_extent > 0.0 { - let extent = (axis_extent + s) * 1.5; - draw_axes( - frame, - view_rot, - eye, - bounds, - extent.max(10.0), - grid_origin, - (gx, gy, gz), - ); + if limits_extent > 0.0 { + axis_extent = limits_extent; } - return; + return GridGeometry { segments: all_segments, axis_extent }; } - let mut axis_extent = 0.0_f32; - // Lines parallel to axis2 (varying axis1 position). if let Some((anchor_screen, anchor_world, gap)) = best_anchor(0) { if gap >= MIN_HORIZON_GRID_PX { @@ -1766,7 +1885,7 @@ fn draw_grid( segments.extend(trim_line(0, p0, p1)); } } - draw_segments(frame, &segments); + all_segments.extend(segments); axis_extent = axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs())); } @@ -1789,18 +1908,15 @@ fn draw_grid( segments.extend(trim_line(1, p0, p1)); } } - draw_segments(frame, &segments); + all_segments.extend(segments); axis_extent = axis_extent.max(min1.abs().max(max1.abs()).max(min2.abs()).max(max2.abs())); } } } - // Coloured axes drawn on top of the grid lines, along the same UCS basis. - if axis_extent > 0.0 { - let extent = (axis_extent + s) * 1.5; - draw_axes(frame, view_rot, eye, bounds, extent.max(10.0), grid_origin, (gx, gy, gz)); - } + let _ = gz; // gz unused after move; retained for symmetry with `draw_axes` call sites. + GridGeometry { segments: all_segments, axis_extent } } // ── Coloured UCS axes ────────────────────────────────────────────────────── @@ -2779,3 +2895,392 @@ mod clip_tests { assert!(clip_seg(Point::new(-9000.0, -9000.0), Point::new(-8000.0, -8000.0), b()).is_none()); } } + +#[cfg(test)] +mod bench_grid_geometry_tests { + use super::*; + use std::hint::black_box; + use std::time::Instant; + + /// Benchmarks the pure grid geometry construction (uncached). + /// Represents a 2-pane tiled Model layout: pane 1 at x=0..1280, pane 2 at + /// x=1280..1920. Slight tilt, typical eye, step 80 (pane 1) / 160 (pane 2). + /// RED: requires `grid_segments(...)` which does not exist yet — compilation + /// must fail with E0425 "cannot find function `grid_segments`". The bench + /// becomes meaningful at Step 1 once the helper is extracted. + #[test] + #[ignore] + fn bench_grid_geometry_uncached() { + let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye1 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds1 = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + let step1 = 80.0_f32; + let grid_origin1 = glam::DVec3::new(0.0, 0.0, 0.0); + let grid_axes1 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits1: Option<(glam::DVec2, glam::DVec2)> = None; + + let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye2 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds2 = iced::Rectangle { + x: 1280.0, + y: 0.0, + width: 640.0, + height: 720.0, + }; + let step2 = 160.0_f32; + let grid_origin2 = glam::DVec3::new(0.0, 0.0, 0.0); + let grid_axes2 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits2: Option<(glam::DVec2, glam::DVec2)> = None; + + for _ in 0..20 { + let _ = black_box(grid_segments( + black_box(view_rot1), + black_box(eye1), + black_box(bounds1), + black_box(step1), + black_box(grid_origin1), + black_box(grid_axes1), + black_box(limits1), + )); + let _ = black_box(grid_segments( + black_box(view_rot2), + black_box(eye2), + black_box(bounds2), + black_box(step2), + black_box(grid_origin2), + black_box(grid_axes2), + black_box(limits2), + )); + } + + let n = 200u32; + let start = Instant::now(); + for _ in 0..n { + let _ = black_box(grid_segments( + black_box(view_rot1), + black_box(eye1), + black_box(bounds1), + black_box(step1), + black_box(grid_origin1), + black_box(grid_axes1), + black_box(limits1), + )); + let _ = black_box(grid_segments( + black_box(view_rot2), + black_box(eye2), + black_box(bounds2), + black_box(step2), + black_box(grid_origin2), + black_box(grid_axes2), + black_box(limits2), + )); + } + let elapsed = start.elapsed(); + let per_frame = elapsed / n; + println!( + "grid_segments uncached: {:?} per frame (n = {}, total {:?})", + per_frame, n, elapsed + ); + assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive"); + } + + /// A/B partner of `bench_grid_geometry_uncached` (Mission #1, step 6). + /// Times the hit-path decision only: build `GridKey` from the current + /// pane params + canvas bounds, borrow the stored key, call + /// `should_reuse`. Mirrors the body of the hit branch in + /// `GridCanvas::draw`. Excludes the iced `canvas::Cache` internals + /// (Arc-clone + draw_with_bounds fast path) because they live in the + /// fork and are not what we added; measures only the cost we own. + #[test] + #[ignore] + fn bench_grid_geometry_cached() { + let view_rot1 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye1 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds1 = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; + let step1 = 80.0_f32; + let origin1 = glam::DVec3::new(0.0, 0.0, 0.0); + let axes1 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits1: Option<(glam::DVec2, glam::DVec2)> = None; + + let view_rot2 = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye2 = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds2 = iced::Rectangle { x: 1280.0, y: 0.0, width: 640.0, height: 720.0 }; + let step2 = 160.0_f32; + let origin2 = glam::DVec3::new(0.0, 0.0, 0.0); + let axes2 = (Vec3::X, Vec3::Y, Vec3::Z); + let limits2: Option<(glam::DVec2, glam::DVec2)> = None; + + let params1 = GridParams { + view_rot: view_rot1, eye: eye1, bounds: bounds1, step: step1, + origin: origin1, axes: axes1, limits: limits1, + }; + let params2 = GridParams { + view_rot: view_rot2, eye: eye2, bounds: bounds2, step: step2, + origin: origin2, axes: axes2, limits: limits2, + }; + let grids = vec![params1, params2]; + // Overall canvas bounds — what `GridCanvas::draw` receives and + // passes to `GridKey::from_grids`. The 1920×720 covers the two + // tiled panes (1280 + 640). + let canvas_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + + // Pre-seed a `GridCanvasState` with the same key the bench will + // build each iteration — guaranteed hit path. + let state = GridCanvasState::default(); + let stored_key = GridKey::from_grids(&grids, canvas_bounds); + *state.key.borrow_mut() = Some(stored_key); + + for _ in 0..20 { + let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds)); + let hit = should_reuse(state.key.borrow().as_ref(), &key); + black_box(hit); + } + + let n = 200u32; + let start = Instant::now(); + let mut hit_count = 0u32; + for _ in 0..n { + let key = GridKey::from_grids(black_box(&grids), black_box(canvas_bounds)); + if should_reuse(state.key.borrow().as_ref(), &key) { + hit_count += 1; + } + } + let elapsed = start.elapsed(); + let per_frame = elapsed / n; + assert_eq!(hit_count, n, "bench should always hit (sanity)"); + println!( + "grid key + should_reuse (hit path): {:?} per frame (n = {}, total {:?})", + per_frame, n, elapsed + ); + assert!(per_frame.as_secs_f64() > 0.0, "per-frame time must be positive"); + } +} + +#[cfg(test)] +mod grid_key_tests { + use super::*; + + /// Reference `GridParams` used as the baseline for key-construction tests. + /// Mirrors a representative Model pane: identity-ish view, eye ~3.5m back, + /// step 80 world units, WCS, no limits. Tests mutate one field at a time + /// off this baseline to assert that `GridKey` invalidates on every input + /// change. + fn baseline_params() -> GridParams { + GridParams { + view_rot: Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05), + eye: glam::DVec3::new(4.0, 3.5, 9.0), + bounds: iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }, + step: 80.0, + origin: glam::DVec3::new(0.0, 0.0, 0.0), + axes: (Vec3::X, Vec3::Y, Vec3::Z), + limits: None, + } + } + + /// Same `Vec` + same bounds ⇒ keys compare equal. + #[test] + fn grid_key_matches_identical_params() { + let grids = vec![baseline_params(), baseline_params()]; + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let a = GridKey::from_grids(&grids, bounds); + let b = GridKey::from_grids(&grids, bounds); + assert_eq!(a, b); + } + + /// One test, one baseline. Every change of any of the 7 inputs must produce + /// a key that differs from the baseline. This is the entire correctness + /// contract for cache hit/miss — if any input is ignored, the cache serves + /// a stale grid. + #[test] + fn grid_key_invalidates_on_changed_fields() { + let baseline_bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1920.0, + height: 720.0, + }; + let baseline_grids = vec![baseline_params()]; + let baseline_key = GridKey::from_grids(&baseline_grids, baseline_bounds); + + // view_rot: small extra rotation + let mut p = baseline_params(); + p.view_rot = Mat4::from_rotation_x(0.15 + 0.01) * Mat4::from_rotation_y(0.05); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "view_rot change must invalidate" + ); + + // eye: shift in z + let mut p = baseline_params(); + p.eye = glam::DVec3::new(4.0, 3.5, 9.5); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "eye change must invalidate" + ); + + // step: zoom in + let mut p = baseline_params(); + p.step = 40.0; + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "step change must invalidate" + ); + + // origin: translate the UCS origin off-zero + let mut p = baseline_params(); + p.origin = glam::DVec3::new(100.0, 0.0, 0.0); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "origin change must invalidate" + ); + + // axes: rotate the active UCS + let mut p = baseline_params(); + p.axes = (Vec3::Y, Vec3::X, Vec3::Z); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "axes change must invalidate" + ); + + // limits: switch from None to Some + let mut p = baseline_params(); + p.limits = Some((glam::DVec2::new(0.0, 0.0), glam::DVec2::new(100.0, 100.0))); + assert_ne!( + GridKey::from_grids(&[p], baseline_bounds), + baseline_key, + "limits change must invalidate" + ); + + // bounds: same baseline GridParams but a different overlay bounds + let other_bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: 1280.0, + height: 720.0, + }; + assert_ne!( + GridKey::from_grids(&baseline_grids, other_bounds), + baseline_key, + "bounds change must invalidate" + ); + } + + /// In a 2-pane tiled layout, changing the second pane's params (with pane 1 + /// unchanged) must produce a different key — the cache cannot share geometry + /// when any pane is dirty. + #[test] + fn grid_key_invalidates_when_any_pane_changes() { + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let pane1 = baseline_params(); + let pane2 = baseline_params(); + let both = vec![pane1.clone(), pane2.clone()]; + let baseline = GridKey::from_grids(&both, bounds); + + let mut pane2_changed = pane2; + pane2_changed.step = 160.0; + let dirty = vec![pane1, pane2_changed]; + assert_ne!( + GridKey::from_grids(&dirty, bounds), + baseline, + "second pane change must invalidate" + ); + } + + /// `should_reuse(None, &key)` ⇒ `false` (no cached key to reuse). + #[test] + fn should_reuse_empty() { + let grids = vec![baseline_params()]; + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let key = GridKey::from_grids(&grids, bounds); + assert!(!should_reuse(None, &key)); + } + + /// `should_reuse(Some(&old), &same)` ⇒ `true` (structural equality). + #[test] + fn should_reuse_equal() { + let grids = vec![baseline_params()]; + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let key = GridKey::from_grids(&grids, bounds); + assert!(should_reuse(Some(&key), &key)); + } + + /// `should_reuse(Some(&old), &new)` with keys built from different inputs + /// ⇒ `false` (must recompute, not serve stale geometry). + #[test] + fn should_reuse_changed() { + let grids_a = vec![baseline_params()]; + let mut pane2 = baseline_params(); + pane2.step = 160.0; + let grids_b = vec![pane2]; + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1920.0, height: 720.0 }; + let old = GridKey::from_grids(&grids_a, bounds); + let new = GridKey::from_grids(&grids_b, bounds); + assert!(!should_reuse(Some(&old), &new)); + } +} + +#[cfg(test)] +mod grid_canvas_state_tests { + use super::*; + + /// A freshly-defaulted `GridCanvasState` must have no cached key — the + /// first draw of a session always misses. This pins the `Default` impl + /// to a usable empty state (no need for the wrapper to special-case it). + #[test] + fn default_state_has_no_cached_key() { + let state = GridCanvasState::default(); + assert!(state.key.borrow().is_none()); + } + + /// After manually storing a key in the state (the same way `draw` will + /// on a miss), `should_reuse` must return `true` for the stored key and + /// `false` for a key built from different inputs. This exercises the + /// state → decision wiring end-to-end without a real iced `Renderer`. + #[test] + fn stored_key_is_recognized_by_should_reuse() { + let view_rot = Mat4::from_rotation_x(0.15) * Mat4::from_rotation_y(0.05); + let eye = glam::DVec3::new(4.0, 3.5, 9.0); + let bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 1280.0, height: 720.0 }; + let step = 80.0_f32; + let grid_origin = glam::DVec3::new(0.0, 0.0, 0.0); + let grid_axes = (Vec3::X, Vec3::Y, Vec3::Z); + let limits: Option<(glam::DVec2, glam::DVec2)> = None; + + let params = GridParams { + view_rot, + eye, + bounds, + step, + origin: grid_origin, + axes: grid_axes, + limits, + }; + let key = GridKey::from_grids(&[params], bounds); + + let state = GridCanvasState::default(); + *state.key.borrow_mut() = Some(key.clone()); + + // Same key in the cache and in the request ⇒ reuse. + assert!(should_reuse(state.key.borrow().as_ref(), &key)); + + // Different bounds on the same params ⇒ different key, do not reuse. + let other_bounds = iced::Rectangle { x: 0.0, y: 0.0, width: 640.0, height: 480.0 }; + let other_key = GridKey::from_grids(&[params], other_bounds); + assert!(!should_reuse(state.key.borrow().as_ref(), &other_key)); + } +}