From eba4fb87a818d8c427d8818f86d93f41934f0464 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:01:18 +0300 Subject: [PATCH 1/3] Complete associative centerline workflow --- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- src/app/commands/draw.rs | 36 ++- src/app/commands/styleprops.rs | 40 ++- src/app/properties.rs | 30 ++ src/entities/line.rs | 126 ++++++++- src/entities/traits.rs | 8 + src/io/linetypes.rs | 11 +- src/modules/draw/draw/centerline.rs | 272 +++++-------------- src/scene/centerline.rs | 361 +++++++++++++++++++++++++ src/scene/mod.rs | 6 + 14 files changed, 683 insertions(+), 223 deletions(-) create mode 100644 src/scene/centerline.rs diff --git a/Cargo.lock b/Cargo.lock index 395e2d54..42be7cf9 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=0908da7#0908da7b6e4f702a6c78359a57f53e2b79cf39eb" +source = "git+https://github.com/ramox81/cadcodec.git?rev=4f929bd#4f929bda328b2024eb408b69e70d5fc80a8b22ef" 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=ebeb2ec#ebeb2ecc2d17d92c588b0fa8bbe3864b156bc71f" +source = "git+https://github.com/ramox81/cadkernel.git?rev=33fd678#33fd678578de7d47c913121f147382c9d73d7830" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 8cbeac47..00680472 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 = "0908da7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "ebeb2ec", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "4f929bd", features = ["serde"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "33fd678", 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/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 3d393aa2..51717d04 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 = "0908da7", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "4f929bd", 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 = "0908da7", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "4f929bd", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 671ef9ea..1625721c 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 = "0908da7", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "4f929bd", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index e9f6cfb1..a288337a 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = "0.3.4" +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "4f929bd", features = ["serde"] } diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 4f32b015..6433fc08 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -473,11 +473,45 @@ impl OpenCADStudio { "CENTERLINE" => { use crate::modules::draw::draw::centerline::CenterLineCommand; - let new_cmd = CenterLineCommand::new(); + let settings = self.tabs[i].scene.centerline_settings(); + let new_cmd = CenterLineCommand::new(settings); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "CENTERRESET" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + self.push_undo_snapshot(i, "CENTERRESET"); + let count = self.tabs[i].scene.reset_centerlines(&handles); + if count > 0 { + self.tabs[i].dirty = true; + } + self.command_line + .push_output(&format!("CENTERRESET: {count} centerline(s) updated.")); + } + + "CENTERREASSOCIATE" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + self.push_undo_snapshot(i, "CENTERREASSOCIATE"); + let count = self.tabs[i].scene.set_centerline_association(&handles, true); + if count > 0 { + self.tabs[i].dirty = true; + } + self.command_line + .push_output(&format!("CENTERREASSOCIATE: {count} centerline(s) associated.")); + } + + "CENTERDISASSOCIATE" => { + let handles = self.tabs[i].scene.selected_handles_in_order(); + self.push_undo_snapshot(i, "CENTERDISASSOCIATE"); + let count = self.tabs[i].scene.set_centerline_association(&handles, false); + if count > 0 { + self.tabs[i].dirty = true; + } + self.command_line + .push_output(&format!("CENTERDISASSOCIATE: {count} centerline(s) detached.")); + } + "DIMCENTER" | "CENTERMARK" => { use crate::modules::draw::draw::dimcenter::DimCenterCommand; let new_cmd = DimCenterCommand::new(); diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 26635003..6dca6de4 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -933,6 +933,11 @@ impl OpenCADStudio { | "SKETCHINC" | "SKPOLY" | "SKTOLERANCE" + | "CENTEREXE" + | "CENTERLAYER" + | "CENTERLTYPE" + | "CENTERLTSCALE" + | "CENTERLTYPEFILE" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -955,7 +960,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 | 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 CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { let frame_kind = crate::scene::frame::kind_for_name(&name); @@ -1032,6 +1037,39 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if matches!( + name.as_str(), + "CENTEREXE" + | "CENTERLAYER" + | "CENTERLTYPE" + | "CENTERLTSCALE" + | "CENTERLTYPEFILE" + ) { + let settings = self.tabs[i].scene.centerline_settings(); + let current = match name.as_str() { + "CENTEREXE" => settings.extension.to_string(), + "CENTERLAYER" => settings.layer, + "CENTERLTYPE" => settings.linetype, + "CENTERLTSCALE" => settings.linetype_scale.to_string(), + "CENTERLTYPEFILE" => settings.linetype_file, + _ => unreachable!(), + }; + if let Some(value) = &value { + match self.tabs[i].scene.set_centerline_setting(&name, value) { + Ok(message) => { + self.tabs[i].dirty = true; + self.command_line.push_output(&message); + } + Err(error) => self.command_line.push_error(&error), + } + } 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/app/properties.rs b/src/app/properties.rs index 94ba10d7..ae41ebb9 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1844,6 +1844,36 @@ impl OpenCADStudio { &mut entity, ); + // Smart centre lines carry their own drawing-level creation style. + // Apply it after the generic ribbon style so ordinary LINE entities + // keep the existing path while centre lines honour their settings. + if acadrust::entities::CenterLineAssociation::read( + &entity.common().extended_data, + ) + .is_some() + { + let settings = self.tabs[i].scene.centerline_settings(); + if !settings.layer.eq_ignore_ascii_case("Current") { + entity.common_mut().layer = settings.layer; + } + if !settings.linetype.eq_ignore_ascii_case("Current") { + entity.common_mut().linetype = settings.linetype; + } + entity.common_mut().linetype_scale = settings.linetype_scale; + if !self.tabs[i] + .scene + .document + .app_ids + .contains(acadrust::entities::CENTERLINE_XDATA_APPLICATION) + { + let mut app = acadrust::tables::AppId::new( + acadrust::entities::CENTERLINE_XDATA_APPLICATION, + ); + app.handle = self.tabs[i].scene.document.allocate_handle(); + let _ = self.tabs[i].scene.document.app_ids.add(app); + } + } + let text_style_annotative = match &entity { acadrust::EntityType::Text(text) => { crate::scene::annotative::text_style_is_annotative( diff --git a/src/entities/line.rs b/src/entities/line.rs index cab1605c..851082fe 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -3,7 +3,8 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ - center_grip, edit_prop as edit, parse_f64, ro_prop as ro, square_grip, + center_grip, edit_prop as edit, oriented_triangle_grip, parse_f64, ro_prop as ro, + square_grip, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -67,10 +68,51 @@ fn grips(line: &Line) -> Vec { let s = glam::DVec3::new(line.start.x, line.start.y, line.start.z); let e = glam::DVec3::new(line.end.x, line.end.y, line.end.z); let m = (s + e) * 0.5; + if let Some(association) = + acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) + { + let direction = (e - s).normalize_or(glam::DVec3::X); + let start_extension = association.start_extension + association.start_length_adjustment; + let end_extension = association.end_extension + association.end_length_adjustment; + let base_start = s + direction * start_extension; + let base_end = e - direction * end_extension; + return vec![ + square_grip(0, base_start), + square_grip(1, base_end), + center_grip(2, m), + oriented_triangle_grip(3, s, -direction), + oriented_triangle_grip(4, e, direction), + ]; + } vec![square_grip(0, s), square_grip(1, e), center_grip(2, m)] } fn properties(line: &Line) -> Vec { + if let Some(association) = + acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) + { + return vec![PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + edit( + "Start extension", + "centerline_start_extension", + association.start_extension, + ), + edit( + "End extension", + "centerline_end_extension", + association.end_extension, + ), + ro(t!("Length").as_ref(), "length", format!("{:.4}", line.length())), + ro( + "Associative", + "centerline_associative", + if association.associated { "Yes" } else { "No" }, + ), + ], + }]; + } let dx = line.end.x - line.start.x; let dy = line.end.y - line.start.y; let dz = line.end.z - line.start.z; @@ -97,6 +139,33 @@ fn apply_geom_prop(line: &mut Line, field: &str, value: &str) { let Some(v) = parse_f64(value) else { return; }; + if let Some(mut association) = + acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) + { + if !v.is_finite() || v < 0.0 { + return; + } + let start = glam::DVec3::new(line.start.x, line.start.y, line.start.z); + let end = glam::DVec3::new(line.end.x, line.end.y, line.end.z); + let direction = (end - start).normalize_or(glam::DVec3::X); + match field { + "centerline_start_extension" => { + let delta = v - association.start_extension; + let moved = start - direction * delta; + line.start = acadrust::types::Vector3::new(moved.x, moved.y, moved.z); + association.start_extension = v; + } + "centerline_end_extension" => { + let delta = v - association.end_extension; + let moved = end + direction * delta; + line.end = acadrust::types::Vector3::new(moved.x, moved.y, moved.z); + association.end_extension = v; + } + _ => return, + } + association.write(&mut line.common.extended_data); + return; + } match field { "start_x" => line.start.x = v, "start_y" => line.start.y = v, @@ -109,6 +178,55 @@ fn apply_geom_prop(line: &mut Line, field: &str, value: &str) { } fn apply_grip(line: &mut Line, grip_id: usize, apply: GripApply) { + if let Some(mut association) = + acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) + { + let start = glam::DVec3::new(line.start.x, line.start.y, line.start.z); + let end = glam::DVec3::new(line.end.x, line.end.y, line.end.z); + let direction = (end - start).normalize_or(glam::DVec3::X); + let start_total = association.start_extension + association.start_length_adjustment; + let end_total = association.end_extension + association.end_length_adjustment; + let base_start = start + direction * start_total; + let base_end = end - direction * end_total; + match (grip_id, apply) { + (0, GripApply::Absolute(point)) => { + let delta = (point - base_start).dot(direction); + association.start_length_adjustment -= delta; + let moved = start + direction * delta; + line.start = acadrust::types::Vector3::new(moved.x, moved.y, moved.z); + } + (1, GripApply::Absolute(point)) => { + let delta = (point - base_end).dot(direction); + association.end_length_adjustment += delta; + let moved = end + direction * delta; + line.end = acadrust::types::Vector3::new(moved.x, moved.y, moved.z); + } + (2, GripApply::Translate(delta)) => { + line.start.x += delta.x; + line.start.y += delta.y; + line.start.z += delta.z; + line.end.x += delta.x; + line.end.y += delta.y; + line.end.z += delta.z; + association.associated = false; + } + (3, GripApply::Absolute(point)) => { + let total = (base_start - point).dot(direction).max(0.0); + association.start_extension = (total - association.start_length_adjustment).max(0.0); + let moved = base_start - direction * (association.start_extension + association.start_length_adjustment); + line.start = acadrust::types::Vector3::new(moved.x, moved.y, moved.z); + } + (4, GripApply::Absolute(point)) => { + let total = (point - base_end).dot(direction).max(0.0); + association.end_extension = (total - association.end_length_adjustment).max(0.0); + let moved = base_end + direction * (association.end_extension + association.end_length_adjustment); + line.end = acadrust::types::Vector3::new(moved.x, moved.y, moved.z); + } + _ => return, + } + association.write(&mut line.common.extended_data); + return; + } match (grip_id, apply) { (0, GripApply::Absolute(p)) => { line.start.x = p.x as f64; @@ -133,6 +251,12 @@ fn apply_grip(line: &mut Line, grip_id: usize, apply: GripApply) { } fn apply_transform(line: &mut Line, t: &EntityTransform) { + if let Some(mut association) = + acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) + { + association.associated = false; + association.write(&mut line.common.extended_data); + } match t { EntityTransform::Translate(d) => { line.translate(acadrust::types::Vector3::new( diff --git a/src/entities/traits.rs b/src/entities/traits.rs index 5569e69d..ed736a43 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -111,6 +111,14 @@ pub trait TextContent { pub fn entity_type_name(et: &EntityType) -> &str { match et { EntityType::Point(_) => "Point", + EntityType::Line(line) + if acadrust::entities::CenterLineAssociation::read( + &line.common.extended_data, + ) + .is_some() => + { + "CenterLine" + } EntityType::Line(_) => "Line", EntityType::Circle(_) => "Circle", EntityType::Arc(_) => "Arc", diff --git a/src/io/linetypes.rs b/src/io/linetypes.rs index 8ec9df32..5ce41348 100644 --- a/src/io/linetypes.rs +++ b/src/io/linetypes.rs @@ -265,12 +265,21 @@ pub fn extract_pattern(desc: &str) -> String { /// Add all standard OpenCADStudio linetypes to `doc`, skipping existing ones. pub fn populate_document(doc: &mut CadDocument) { - for mut lt in parse(LIN_SOURCE) { + populate_document_from_source(doc, LIN_SOURCE); +} + +/// Add all simple linetypes parsed from a caller-supplied `.lin` source. +/// Returns the number of definitions newly registered in the drawing. +pub fn populate_document_from_source(doc: &mut CadDocument, source: &str) -> usize { + let mut added = 0; + for mut lt in parse(source) { if !doc.line_types.contains(<.name) { lt.set_handle(doc.allocate_handle()); doc.line_types.add(lt).ok(); + added += 1; } } + added } diff --git a/src/modules/draw/draw/centerline.rs b/src/modules/draw/draw/centerline.rs index 789416bb..d02d92e1 100644 --- a/src/modules/draw/draw/centerline.rs +++ b/src/modules/draw/draw/centerline.rs @@ -1,218 +1,50 @@ -// Centre line tool — interactive command. -// -// Command: CENTERLINE — pick two straight Line entities and draw a single -// centre line between them, committed as one Line entity. -// -// * If the two picked lines are (near) parallel, the result is the midline: -// a line running between the two, centred on the average of their -// midpoints, oriented along the averaged direction, with a length equal to -// the longer of the two source lines. -// * If the two picked lines cross, the result is the bisector of the acute -// angle at their intersection point, drawn symmetrically either side of -// that point. -// -// The command picks the first line on the first click, stores it, prompts for -// the second line, then computes the centre line and ends. +//! Associative centre-line construction from lines and linear polyline segments. +use acadrust::entities::{CenterLineAssociation, CenterLineSource}; use acadrust::types::Vector3; -use acadrust::{EntityType, Handle, Line}; +use acadrust::{EntityType, Handle}; use glam::DVec3; -use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::centerline::{construct_line, picked_source, CenterLineSettings}; +use crate::t; -// ── Ribbon definition ───────────────────────────────────────────────────── - -#[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line +#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "CENTERLINE", label: "Center Line", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), - event: ModuleEvent::Command("CENTERLINE".to_string()), + event: ModuleEvent::Command("CENTERLINE".to_owned()), } } -// ── Geometry helpers ────────────────────────────────────────────────────── - -/// A line reduced to the quantities the centre-line maths needs. -#[derive(Clone, Copy)] -struct LineGeom { +#[derive(Clone)] +struct PickedSource { + source: CenterLineSource, start: DVec3, end: DVec3, } -impl LineGeom { - fn from_line(line: &Line) -> Self { - Self { - start: DVec3::new(line.start.x, line.start.y, line.start.z), - end: DVec3::new(line.end.x, line.end.y, line.end.z), - } - } - - fn midpoint(&self) -> DVec3 { - (self.start + self.end) * 0.5 - } - - fn length(&self) -> f64 { - (self.end - self.start).length() - } - - /// Normalized direction in 3D, or `None` if the line is degenerate. - fn dir(&self) -> Option { - let d = self.end - self.start; - let len = d.length(); - if len <= 1e-9 { - None - } else { - Some(d / len) - } - } +fn vector(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) } -/// 2D cross product of the XY components of two vectors. -fn cross_xy(a: DVec3, b: DVec3) -> f64 { - a.x * b.y - a.y * b.x -} - -fn to_v3(p: DVec3) -> Vector3 { - Vector3::new(p.x, p.y, p.z) -} - -/// Compute the centre line between two source lines. `None` when the result -/// would be degenerate (zero-length sources, or coincident parallel lines that -/// give no usable direction). -fn compute_center_line(a: LineGeom, b: LineGeom) -> Option { - let da = a.dir()?; - let db = b.dir()?; - - // Average Z of the four endpoints keeps the result on a sensible plane even - // when the picked lines sit at slightly different elevations. - let avg_z = (a.start.z + a.end.z + b.start.z + b.end.z) * 0.25; - - let parallel = cross_xy(da, db).abs() <= 1e-9; - - if parallel { - // Midline: average direction (flip db so the two add constructively), - // centred on the average of the two midpoints, length = longer source. - let db_aligned = if da.dot(db) < 0.0 { -db } else { db }; - let avg = da + db_aligned; - let dir = if avg.length() <= 1e-9 { - da - } else { - avg / avg.length() - }; - let dir = DVec3::new(dir.x, dir.y, 0.0); - let dir = if dir.length() <= 1e-9 { - return None; - } else { - dir / dir.length() - }; - - let mid = (a.midpoint() + b.midpoint()) * 0.5; - let half = a.length().max(b.length()) * 0.5; - if half <= 1e-9 { - return None; - } - let center = DVec3::new(mid.x, mid.y, avg_z); - let p0 = center - dir * half; - let p1 = center + dir * half; - Some(Line::from_points(to_v3(p0), to_v3(p1))) - } else { - // Intersecting: bisector of the acute angle at the intersection point. - // Solve the 2D line-line intersection in XY. - // a.start + t*da = b.start + s*db - let r = da; // direction of line a - let s = db; // direction of line b - let denom = cross_xy(r, s); - if denom.abs() <= 1e-12 { - return None; - } - let qp = b.start - a.start; - let t = cross_xy(qp, s) / denom; - let ix = a.start.x + r.x * t; - let iy = a.start.y + r.y * t; - let p = DVec3::new(ix, iy, avg_z); - - // Orient both directions away from the intersection point, choosing the - // half of each line whose midpoint lies on the side we keep, so the two - // oriented directions bisect the acute angle between the lines. - let a_away = orient_away(p, a); - let b_away = orient_away(p, b); - // For an acute-angle bisector, flip b_away if the two oriented dirs open - // obtuse (their dot is negative). - let b_oriented = if a_away.dot(b_away) < 0.0 { - -b_away - } else { - b_away - }; - let bis = a_away + b_oriented; - let bis = DVec3::new(bis.x, bis.y, 0.0); - if bis.length() <= 1e-9 { - return None; - } - let dir = bis / bis.length(); - - let half = ((a.length() + b.length()) * 0.5) * 0.5; - if half <= 1e-9 { - return None; - } - let p0 = p - dir * half; - let p1 = p + dir * half; - Some(Line::from_points(to_v3(p0), to_v3(p1))) - } -} - -/// Unit direction from `p` toward the farther endpoint of `line` (the side that -/// extends away from the intersection point). -fn orient_away(p: DVec3, line: LineGeom) -> DVec3 { - let to_start = line.start - p; - let to_end = line.end - p; - let pick = if to_end.length() >= to_start.length() { - to_end - } else { - to_start - }; - let len = pick.length(); - if len <= 1e-9 { - // Fall back to the raw line direction if the pick degenerates. - line.dir().unwrap_or(DVec3::X) - } else { - pick / len - } -} - -// ── Command implementation ──────────────────────────────────────────────── - pub struct CenterLineCommand { - /// First picked Line's geometry, captured once the first pick lands. - first: Option, - /// The entity injected by the host before `on_entity_pick` runs; `None` - /// until the host injects it. + first: Option, picked: Option, plane: WorkingPlane, + settings: CenterLineSettings, } impl CenterLineCommand { - pub fn new() -> Self { + pub(crate) fn new(settings: CenterLineSettings) -> Self { Self { first: None, picked: None, plane: WorkingPlane::default(), - } - } - - /// Extract a `LineGeom` from a picked entity; `None` for anything else. - fn as_line(&self, entity: &EntityType) -> Option { - match entity { - EntityType::Line(line) => { - let mut geom = LineGeom::from_line(line); - geom.start = self.plane.to_local(geom.start); - geom.end = self.plane.to_local(geom.end); - Some(geom) - } - _ => None, + settings, } } } @@ -246,39 +78,53 @@ impl CadCommand for CenterLineCommand { self.picked = Some(entity); } - fn on_entity_pick(&mut self, handle: Handle, _pt: DVec3) -> CmdResult { + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { if handle.is_null() { return CmdResult::NeedPoint; } - // Resolve the just-picked entity; ignore the pick if it isn't a Line. - let picked = self.picked.take(); - let geom = match picked.as_ref().and_then(|entity| self.as_line(entity)) { - Some(g) => g, - None => return CmdResult::NeedPoint, + let Some(entity) = self.picked.take() else { + return CmdResult::NeedPoint; }; - // Reject zero-length picks rather than corrupting the result. - if geom.length() <= 1e-9 { + let Some((source, start, end)) = picked_source(&entity, handle, point) else { + return CmdResult::NeedPoint; + }; + let picked = PickedSource { source, start, end }; + let Some(first) = self.first.take() else { + self.first = Some(picked); + return CmdResult::NeedPoint; + }; + if first.source.handle == picked.source.handle + && first.source.segment_index == picked.source.segment_index + { + self.first = Some(first); return CmdResult::NeedPoint; } - match self.first { - None => { - // First line captured; keep prompting for the second. - self.first = Some(geom); - CmdResult::NeedPoint - } - Some(first) => match compute_center_line(first, geom) { - Some(line) => CmdResult::CommitAndExit( - self.plane.place_entity(EntityType::Line(line)), - ), - // Degenerate combination (coincident / no usable result): - // keep prompting for a usable second line. - None => CmdResult::NeedPoint, - }, - } + let association = CenterLineAssociation { + first: first.source.clone(), + second: picked.source.clone(), + plane_origin: vector(self.plane.origin), + plane_x: vector(self.plane.x), + plane_y: vector(self.plane.y), + start_extension: self.settings.extension, + end_extension: self.settings.extension, + start_length_adjustment: 0.0, + end_length_adjustment: 0.0, + associated: true, + }; + let Some(mut line) = construct_line( + (first.start, first.end), + (picked.start, picked.end), + &association, + ) else { + self.first = Some(first); + return CmdResult::NeedPoint; + }; + association.write(&mut line.common.extended_data); + CmdResult::CommitAndExit(EntityType::Line(line)) } - fn on_point(&mut self, _pt: DVec3) -> CmdResult { + fn on_point(&mut self, _point: DVec3) -> CmdResult { CmdResult::NeedPoint } @@ -291,7 +137,11 @@ impl CadCommand for CenterLineCommand { } } -// ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { - names: &["CENTERLINE"] -}); // CenterLineCommand + names: &[ + "CENTERLINE", + "CENTERRESET", + "CENTERREASSOCIATE", + "CENTERDISASSOCIATE", + ] +}); diff --git a/src/scene/centerline.rs b/src/scene/centerline.rs new file mode 100644 index 00000000..bdb55bfb --- /dev/null +++ b/src/scene/centerline.rs @@ -0,0 +1,361 @@ +use super::*; +use acadrust::entities::{ + CenterLineAssociation, CenterLineSource, CenterLineSourceKind, +}; +use acadrust::objects::{XRecordEntry, XRecordValue}; +use acadrust::types::Vector3; +use cadkernel::geom2d::{centerline_between, Line as KernelLine}; +use glam::DVec3; +use crate::command::WorkingPlane; + +const SETTINGS_RECORD: &str = "OPEN_CAD_CENTERLINE_SETTINGS"; + +#[derive(Clone, Debug)] +pub(crate) struct CenterLineSettings { + pub extension: f64, + pub layer: String, + pub linetype: String, + pub linetype_scale: f64, + pub linetype_file: String, +} + +impl Default for CenterLineSettings { + fn default() -> Self { + Self { + extension: 0.12, + layer: "Current".to_owned(), + linetype: "CENTER".to_owned(), + linetype_scale: 1.0, + linetype_file: String::new(), + } + } +} + +fn vector(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 ocs_point(point: (f64, f64, f64), normal: Vector3) -> DVec3 { + let point = crate::scene::view::transform::ocs_point_to_wcs( + point, + (normal.x, normal.y, normal.z), + ); + DVec3::new(point.0, point.1, point.2) +} + +fn distance_to_segment(point: DVec3, start: DVec3, end: DVec3) -> f64 { + let span = end - start; + let length_squared = span.length_squared(); + if length_squared <= 1.0e-20 { + return point.distance(start); + } + let parameter = ((point - start).dot(span) / length_squared).clamp(0.0, 1.0); + point.distance(start + span * parameter) +} + +fn segment_indices(vertex_count: usize, closed: bool) -> impl Iterator { + let count = if closed && vertex_count > 2 { + vertex_count + } else { + vertex_count.saturating_sub(1) + }; + (0..count).map(move |start| (start, (start + 1) % vertex_count.max(1))) +} + +/// Resolve a user pick to a line or the closest linear polyline segment. +pub(crate) fn picked_source( + entity: &EntityType, + handle: Handle, + pick: DVec3, +) -> Option<(CenterLineSource, DVec3, DVec3)> { + match entity { + EntityType::Line(line) if line.length() > 1.0e-10 => Some(( + CenterLineSource { + handle, + kind: CenterLineSourceKind::Line, + segment_index: -1, + pick_point: vector(pick), + }, + dvec(line.start), + dvec(line.end), + )), + EntityType::LwPolyline(polyline) => segment_indices(polyline.vertices.len(), polyline.is_closed) + .filter(|(start, _)| polyline.vertices[*start].bulge.abs() <= 1.0e-12) + .filter_map(|(start, end)| { + let a = polyline.vertices[start].location; + let b = polyline.vertices[end].location; + let a = ocs_point((a.x, a.y, polyline.elevation), polyline.normal); + let b = ocs_point((b.x, b.y, polyline.elevation), polyline.normal); + (a.distance(b) > 1.0e-10).then_some((start, a, b)) + }) + .min_by(|(_, a, b), (_, c, d)| { + distance_to_segment(pick, *a, *b) + .total_cmp(&distance_to_segment(pick, *c, *d)) + }) + .map(|(index, start, end)| ( + CenterLineSource { + handle, + kind: CenterLineSourceKind::LwPolylineSegment, + segment_index: index as i32, + pick_point: vector(pick), + }, + start, + end, + )), + EntityType::Polyline2D(polyline) => segment_indices(polyline.vertices.len(), polyline.is_closed()) + .filter(|(start, _)| polyline.vertices[*start].bulge.abs() <= 1.0e-12) + .filter_map(|(start, end)| { + let a = polyline.vertices[start].location; + let b = polyline.vertices[end].location; + let a = ocs_point((a.x, a.y, polyline.elevation), polyline.normal); + let b = ocs_point((b.x, b.y, polyline.elevation), polyline.normal); + (a.distance(b) > 1.0e-10).then_some((start, a, b)) + }) + .min_by(|(_, a, b), (_, c, d)| { + distance_to_segment(pick, *a, *b) + .total_cmp(&distance_to_segment(pick, *c, *d)) + }) + .map(|(index, start, end)| ( + CenterLineSource { + handle, + kind: CenterLineSourceKind::Polyline2DSegment, + segment_index: index as i32, + pick_point: vector(pick), + }, + start, + end, + )), + _ => None, + } +} + +fn resolve_source(document: &acadrust::CadDocument, source: &CenterLineSource) -> Option<(DVec3, DVec3)> { + let entity = document.get_entity(source.handle)?; + let (_, start, end) = picked_source(entity, source.handle, dvec(source.pick_point))?; + match source.kind { + CenterLineSourceKind::Line => matches!(entity, EntityType::Line(_)).then_some((start, end)), + CenterLineSourceKind::LwPolylineSegment => { + let EntityType::LwPolyline(polyline) = entity else { return None; }; + let index = usize::try_from(source.segment_index).ok()?; + let end_index = if index + 1 < polyline.vertices.len() { index + 1 } else if polyline.is_closed { 0 } else { return None; }; + if polyline.vertices.get(index)?.bulge.abs() > 1.0e-12 { return None; } + let a = polyline.vertices[index].location; + let b = polyline.vertices[end_index].location; + Some(( + ocs_point((a.x, a.y, polyline.elevation), polyline.normal), + ocs_point((b.x, b.y, polyline.elevation), polyline.normal), + )) + } + CenterLineSourceKind::Polyline2DSegment => { + let EntityType::Polyline2D(polyline) = entity else { return None; }; + let index = usize::try_from(source.segment_index).ok()?; + let end_index = if index + 1 < polyline.vertices.len() { index + 1 } else if polyline.is_closed() { 0 } else { return None; }; + if polyline.vertices.get(index)?.bulge.abs() > 1.0e-12 { return None; } + let a = polyline.vertices[index].location; + let b = polyline.vertices[end_index].location; + Some(( + ocs_point((a.x, a.y, polyline.elevation), polyline.normal), + ocs_point((b.x, b.y, polyline.elevation), polyline.normal), + )) + } + } +} + +fn rebuild_line( + document: &acadrust::CadDocument, + association: &CenterLineAssociation, +) -> Option { + let first = resolve_source(document, &association.first)?; + let second = resolve_source(document, &association.second)?; + construct_line(first, second, association) +} + +pub(crate) fn construct_line( + first: (DVec3, DVec3), + second: (DVec3, DVec3), + association: &CenterLineAssociation, +) -> Option { + let plane = WorkingPlane::new( + dvec(association.plane_origin), + dvec(association.plane_x), + dvec(association.plane_y), + ); + let first_start = plane.to_local(first.0); + let first_end = plane.to_local(first.1); + let second_start = plane.to_local(second.0); + let second_end = plane.to_local(second.1); + let first_pick = plane.to_local(dvec(association.first.pick_point)); + let second_pick = plane.to_local(dvec(association.second.pick_point)); + let geometry = centerline_between( + KernelLine { start: [first_start.x, first_start.y], end: [first_end.x, first_end.y] }, + KernelLine { start: [second_start.x, second_start.y], end: [second_end.x, second_end.y] }, + [first_pick.x, first_pick.y], + [second_pick.x, second_pick.y], + association.start_extension + association.start_length_adjustment, + association.end_extension + association.end_length_adjustment, + )?; + let elevation = (first_start.z + first_end.z + second_start.z + second_end.z) * 0.25; + let start = plane.to_world(DVec3::new(geometry.start[0], geometry.start[1], elevation)); + let end = plane.to_world(DVec3::new(geometry.end[0], geometry.end[1], elevation)); + Some(acadrust::Line::from_points(vector(start), vector(end))) +} + +impl Scene { + pub(crate) fn centerline_settings(&self) -> CenterLineSettings { + let defaults = CenterLineSettings::default(); + let Some(layout) = self.current_layout_object_handle() else { return defaults; }; + let Some(record) = self.document.xrecord(layout, SETTINGS_RECORD) else { return defaults; }; + let value = |code| record.entries.iter().find(|entry| entry.code == code).map(|entry| &entry.value); + CenterLineSettings { + extension: value(40).and_then(XRecordValue::as_double).unwrap_or(defaults.extension), + layer: value(1).and_then(XRecordValue::as_string).unwrap_or(&defaults.layer).to_owned(), + linetype: value(2).and_then(XRecordValue::as_string).unwrap_or(&defaults.linetype).to_owned(), + linetype_scale: value(41).and_then(XRecordValue::as_double).unwrap_or(defaults.linetype_scale), + linetype_file: value(3).and_then(XRecordValue::as_string).unwrap_or(&defaults.linetype_file).to_owned(), + } + } + + pub(crate) fn set_centerline_setting(&mut self, name: &str, value: &str) -> Result { + let Some(layout) = self.current_layout_object_handle() else { return Err("No active layout.".to_owned()); }; + if name == "CENTERLTYPE" + && !value.eq_ignore_ascii_case("Current") + && !self.document.line_types.contains(value) + { + return Err(format!("CENTERLTYPE: linetype \"{value}\" is not loaded.")); + } + if name == "CENTERLTYPEFILE" && !value.trim().is_empty() { + #[cfg(not(target_arch = "wasm32"))] + { + let source = std::fs::read_to_string(value) + .map_err(|error| format!("CENTERLTYPEFILE: {error}"))?; + crate::io::linetypes::populate_document_from_source( + &mut self.document, + &source, + ); + } + #[cfg(target_arch = "wasm32")] + return Err("CENTERLTYPEFILE is unavailable in the web build.".to_owned()); + } + self.document.ensure_xrecord(layout, SETTINGS_RECORD); + let record = self.document.xrecord_mut(layout, SETTINGS_RECORD).ok_or_else(|| "Cannot store centerline settings.".to_owned())?; + let (code, replacement, display) = match name { + "CENTEREXE" => { + let number = value.parse::().map_err(|_| "CENTEREXE requires a non-negative number.".to_owned())?; + if !number.is_finite() || number < 0.0 { return Err("CENTEREXE requires a non-negative number.".to_owned()); } + (40, XRecordValue::Double(number), number.to_string()) + } + "CENTERLAYER" => (1, XRecordValue::String(value.to_owned()), value.to_owned()), + "CENTERLTYPE" => (2, XRecordValue::String(value.to_owned()), value.to_owned()), + "CENTERLTSCALE" => { + let number = value.parse::().map_err(|_| "CENTERLTSCALE requires a positive number.".to_owned())?; + if !number.is_finite() || number <= 0.0 { return Err("CENTERLTSCALE requires a positive number.".to_owned()); } + (41, XRecordValue::Double(number), number.to_string()) + } + "CENTERLTYPEFILE" => (3, XRecordValue::String(value.to_owned()), value.to_owned()), + _ => return Err(format!("Unknown centerline setting: {name}")), + }; + if let Some(entry) = record.entries.iter_mut().find(|entry| entry.code == code) { + entry.value = replacement; + } else { + record.entries.push(XRecordEntry { code, value: replacement }); + } + Ok(format!("{name} = {display}")) + } + + pub(crate) fn refresh_associative_centerlines(&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 candidates: Vec<_> = self.document.entities().filter_map(|entity| { + let EntityType::Line(line) = entity else { return None; }; + let association = CenterLineAssociation::read(&line.common.extended_data)?; + (association.associated && (changed.contains(&association.first.handle) || changed.contains(&association.second.handle))) + .then_some((line.common.handle, association)) + }).collect(); + let mut result = Vec::new(); + for (handle, mut association) in candidates { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + let refreshed = resolve_source(&self.document, &association.first) + .zip(resolve_source(&self.document, &association.second)) + .and_then(|(first, second)| construct_line(first, second, &association)); + let Some(mut new_line) = refreshed else { + association.associated = false; + if let Some(EntityType::Line(line)) = self.document.get_entity_mut(handle) { + association.write(&mut line.common.extended_data); + result.push((handle, ChangeKind::Modified)); + } + continue; + }; + if let Some(EntityType::Line(line)) = self.document.get_entity_mut(handle) { + new_line.common = line.common.clone(); + *line = new_line; + result.push((handle, ChangeKind::Modified)); + } + } + result + } + + pub(crate) fn reset_centerlines(&mut self, handles: &[Handle]) -> usize { + let extension = self.centerline_settings().extension; + let candidates: Vec<_> = handles.iter().filter_map(|handle| { + let EntityType::Line(line) = self.document.get_entity(*handle)? else { return None; }; + let mut association = CenterLineAssociation::read(&line.common.extended_data)?; + association.start_extension = extension; + association.end_extension = extension; + rebuild_line(&self.document, &association).map(|line| (*handle, association, line)) + }).collect(); + for (handle, association, rebuilt) in &candidates { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(*handle); + self.record_undo_before(*handle, before); + } + if let Some(EntityType::Line(line)) = self.document.get_entity_mut(*handle) { + let mut rebuilt = rebuilt.clone(); + rebuilt.common = line.common.clone(); + association.write(&mut rebuilt.common.extended_data); + *line = rebuilt; + } + } + if !candidates.is_empty() { + let changes: Vec<_> = candidates.iter().map(|(handle, _, _)| (*handle, ChangeKind::Modified)).collect(); + self.bump_entities(&changes); + } + candidates.len() + } + + pub(crate) fn set_centerline_association(&mut self, handles: &[Handle], associated: bool) -> usize { + let candidates: Vec<_> = handles.iter().filter_map(|handle| { + let EntityType::Line(line) = self.document.get_entity(*handle)? else { return None; }; + let mut association = CenterLineAssociation::read(&line.common.extended_data)?; + association.associated = associated; + let rebuilt = associated.then(|| rebuild_line(&self.document, &association)).flatten(); + (!associated || rebuilt.is_some()).then_some((*handle, association, rebuilt)) + }).collect(); + for (handle, association, rebuilt) in &candidates { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(*handle); + self.record_undo_before(*handle, before); + } + if let Some(EntityType::Line(line)) = self.document.get_entity_mut(*handle) { + if let Some(mut rebuilt) = rebuilt.clone() { + rebuilt.common = line.common.clone(); + association.write(&mut rebuilt.common.extended_data); + *line = rebuilt; + } else { + association.write(&mut line.common.extended_data); + } + } + } + if !candidates.is_empty() { + let changes: Vec<_> = candidates.iter().map(|(handle, _, _)| (*handle, ChangeKind::Modified)).collect(); + self.bump_entities(&changes); + } + candidates.len() + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 04548003..9ecb642e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -21,6 +21,7 @@ pub mod view; // blocks and/or free functions). Pure text-move from the original mod.rs. mod boundary; mod camera_ops; +pub(crate) mod centerline; mod entity; mod group_layer; mod layout; @@ -2393,6 +2394,11 @@ impl Scene { self.associative_hatch_source_cache.borrow_mut().take(); } let mut changes = changes.to_vec(); + for change in self.refresh_associative_centerlines(&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 0ae6355c2cbe74a37f7d0b22b5ab84d64768aa58 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:46:12 +0300 Subject: [PATCH 2/3] Correct shared center settings behavior --- Cargo.lock | 4 ++-- Cargo.toml | 4 ++-- crates/ocs_plugin_api/Cargo.toml | 4 ++-- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- src/scene/centerline.rs | 11 +++++++---- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 42be7cf9..93225764 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/ramox81/cadcodec.git?rev=4f929bd#4f929bda328b2024eb408b69e70d5fc80a8b22ef" +source = "git+https://github.com/ramox81/cadcodec.git?rev=1eeab52#1eeab52a4e743816e5974e1e6441422b5042001d" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=33fd678#33fd678578de7d47c913121f147382c9d73d7830" +source = "git+https://github.com/ramox81/cadkernel.git?rev=50c2551#50c2551d6bd46d19cfa664384bdc312b424269c4" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 00680472..0182714c 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/ramox81/cadcodec.git", rev = "4f929bd", features = ["serde"] } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "33fd678", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1eeab52", features = ["serde"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "50c2551", 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/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 51717d04..d5ec1fb6 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/ramox81/cadcodec.git", rev = "4f929bd", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1eeab52", 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/ramox81/cadcodec.git", rev = "4f929bd", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1eeab52", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 1625721c..3d662239 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/ramox81/cadcodec.git", rev = "4f929bd", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1eeab52", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index a288337a..365b8014 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "4f929bd", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1eeab52", features = ["serde"] } diff --git a/src/scene/centerline.rs b/src/scene/centerline.rs index bdb55bfb..3c8d699d 100644 --- a/src/scene/centerline.rs +++ b/src/scene/centerline.rs @@ -24,7 +24,7 @@ impl Default for CenterLineSettings { Self { extension: 0.12, layer: "Current".to_owned(), - linetype: "CENTER".to_owned(), + linetype: "CENTER2".to_owned(), linetype_scale: 1.0, linetype_file: String::new(), } @@ -206,7 +206,10 @@ pub(crate) fn construct_line( impl Scene { pub(crate) fn centerline_settings(&self) -> CenterLineSettings { - let defaults = CenterLineSettings::default(); + let mut defaults = CenterLineSettings::default(); + if matches!(self.document.header.insertion_units, 4..=7 | 11..=17) { + defaults.extension = 3.5; + } let Some(layout) = self.current_layout_object_handle() else { return defaults; }; let Some(record) = self.document.xrecord(layout, SETTINGS_RECORD) else { return defaults; }; let value = |code| record.entries.iter().find(|entry| entry.code == code).map(|entry| &entry.value); @@ -251,8 +254,8 @@ impl Scene { "CENTERLAYER" => (1, XRecordValue::String(value.to_owned()), value.to_owned()), "CENTERLTYPE" => (2, XRecordValue::String(value.to_owned()), value.to_owned()), "CENTERLTSCALE" => { - let number = value.parse::().map_err(|_| "CENTERLTSCALE requires a positive number.".to_owned())?; - if !number.is_finite() || number <= 0.0 { return Err("CENTERLTSCALE requires a positive number.".to_owned()); } + let number = value.parse::().map_err(|_| "CENTERLTSCALE requires a non-zero number.".to_owned())?; + if !number.is_finite() || number == 0.0 { return Err("CENTERLTSCALE requires a non-zero number.".to_owned()); } (41, XRecordValue::Double(number), number.to_string()) } "CENTERLTYPEFILE" => (3, XRecordValue::String(value.to_owned()), value.to_owned()), From ff09fc7800704ead5fe835cfd3b531feb88087d0 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:05:18 +0300 Subject: [PATCH 3/3] Implement associative center mark workflow --- Cargo.lock | 4 +- Cargo.toml | 4 +- crates/ocs_plugin_api/Cargo.toml | 4 +- crates/ocs_web_worker/Cargo.toml | 2 +- crates/plugin-template-api2/Cargo.toml | 2 +- src/app/command_driver.rs | 20 ++ src/app/commands/draw.rs | 36 ++- src/app/commands/styleprops.rs | 11 +- src/app/properties.rs | 23 +- src/command.rs | 6 + src/entities/line.rs | 258 +++++++++++++++- src/entities/names.rs | 2 + src/entities/traits.rs | 8 + src/modules/draw/draw/dimcenter.rs | 121 +++++++- src/modules/draw/modify/explode.rs | 21 ++ src/scene/centerline.rs | 46 +++ src/scene/centermark.rs | 402 +++++++++++++++++++++++++ src/scene/mod.rs | 6 + 18 files changed, 936 insertions(+), 40 deletions(-) create mode 100644 src/scene/centermark.rs diff --git a/Cargo.lock b/Cargo.lock index 93225764..01919784 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/ramox81/cadcodec.git?rev=1eeab52#1eeab52a4e743816e5974e1e6441422b5042001d" +source = "git+https://github.com/ramox81/cadcodec.git?rev=6dcda1a#6dcda1adc5a99edb1e15fcdeb206c6d65d073828" dependencies = [ "ahash 0.8.12", "anyhow", @@ -878,7 +878,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=50c2551#50c2551d6bd46d19cfa664384bdc312b424269c4" +source = "git+https://github.com/ramox81/cadkernel.git?rev=90922c8#90922c8cf0b6c77f1db01edac56b37ad59f8fc76" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 0182714c..7b2f3a35 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/ramox81/cadcodec.git", rev = "1eeab52", features = ["serde"] } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "50c2551", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", features = ["serde"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "90922c8", 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/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index d5ec1fb6..e367ad7f 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/ramox81/cadcodec.git", rev = "1eeab52", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", 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/ramox81/cadcodec.git", rev = "1eeab52", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index 3d662239..739bc690 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/ramox81/cadcodec.git", rev = "1eeab52", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/crates/plugin-template-api2/Cargo.toml b/crates/plugin-template-api2/Cargo.toml index 365b8014..89cea10e 100644 --- a/crates/plugin-template-api2/Cargo.toml +++ b/crates/plugin-template-api2/Cargo.toml @@ -10,4 +10,4 @@ crate-type = ["cdylib"] [dependencies] ocs_plugin_api = { path = "../ocs_plugin_api", features = ["host"] } -acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "1eeab52", features = ["serde"] } +acadrust = { git = "https://github.com/ramox81/cadcodec.git", rev = "6dcda1a", features = ["serde"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index e0bf9acc..1df31b75 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1495,6 +1495,26 @@ impl OpenCADStudio { } self.refresh_properties(); } + CmdResult::ReassociateCenterMark { target, source, point } => { + if self.reject_locked_edit(i, target) { + return Task::none(); + } + self.push_undo_snapshot(i, "CENTERREASSOCIATE"); + if self.tabs[i].scene.reassociate_center_mark(target, source, point) { + self.tabs[i].dirty = true; + self.command_line.push_output( + "CENTERREASSOCIATE: center mark associated.", + ); + } else { + self.command_line.push_error( + "CENTERREASSOCIATE: the selected source is not circular.", + ); + } + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.refresh_properties(); + } CmdResult::ReplaceEntity(handle, new_entities) => { if self.reject_locked_edit(i, handle) { return Task::none(); diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 6433fc08..c3fd00ed 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -482,43 +482,65 @@ impl OpenCADStudio { "CENTERRESET" => { let handles = self.tabs[i].scene.selected_handles_in_order(); self.push_undo_snapshot(i, "CENTERRESET"); - let count = self.tabs[i].scene.reset_centerlines(&handles); + let count = self.tabs[i].scene.reset_centerlines(&handles) + + self.tabs[i].scene.reset_center_marks(&handles); if count > 0 { self.tabs[i].dirty = true; } self.command_line - .push_output(&format!("CENTERRESET: {count} centerline(s) updated.")); + .push_output(&format!("CENTERRESET: {count} center object(s) updated.")); } "CENTERREASSOCIATE" => { let handles = self.tabs[i].scene.selected_handles_in_order(); + let mark_targets: Vec<_> = handles.iter().copied().filter(|handle| { + let Some(acadrust::EntityType::Line(line)) = self.tabs[i].scene.document.get_entity(*handle) else { return false; }; + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data).is_some() + }).collect(); + if mark_targets.len() == 1 && handles.len() == 1 { + use crate::modules::draw::draw::dimcenter::CenterMarkReassociateCommand; + let new_cmd = CenterMarkReassociateCommand::new(mark_targets[0]); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + return Some(self.finish_dispatch(cmd)); + } self.push_undo_snapshot(i, "CENTERREASSOCIATE"); - let count = self.tabs[i].scene.set_centerline_association(&handles, true); + let count = self.tabs[i].scene.set_centerline_association(&handles, true) + + self.tabs[i].scene.set_center_mark_association(&handles, true); if count > 0 { self.tabs[i].dirty = true; } self.command_line - .push_output(&format!("CENTERREASSOCIATE: {count} centerline(s) associated.")); + .push_output(&format!("CENTERREASSOCIATE: {count} center object(s) associated.")); } "CENTERDISASSOCIATE" => { let handles = self.tabs[i].scene.selected_handles_in_order(); self.push_undo_snapshot(i, "CENTERDISASSOCIATE"); - let count = self.tabs[i].scene.set_centerline_association(&handles, false); + let count = self.tabs[i].scene.set_centerline_association(&handles, false) + + self.tabs[i].scene.set_center_mark_association(&handles, false); if count > 0 { self.tabs[i].dirty = true; } self.command_line - .push_output(&format!("CENTERDISASSOCIATE: {count} centerline(s) detached.")); + .push_output(&format!("CENTERDISASSOCIATE: {count} center object(s) detached.")); } - "DIMCENTER" | "CENTERMARK" => { + "DIMCENTER" => { use crate::modules::draw::draw::dimcenter::DimCenterCommand; let new_cmd = DimCenterCommand::new(); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "CENTERMARK" => { + use crate::modules::draw::draw::dimcenter::CenterMarkCommand; + let settings = self.tabs[i].scene.centerline_settings(); + let new_cmd = CenterMarkCommand::new(settings); + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + "SKETCH" => { use crate::modules::draw::draw::sketch::SketchCommand; let header = &self.tabs[i].scene.document.header; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 6dca6de4..de326abb 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -938,6 +938,9 @@ impl OpenCADStudio { | "CENTERLTYPE" | "CENTERLTSCALE" | "CENTERLTYPEFILE" + | "CENTERCROSSSIZE" + | "CENTERCROSSGAP" + | "CENTERMARKEXE" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -960,7 +963,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 | 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 CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE | CLAYER CELTYPE TEXTSTYLE (read-only)", ); } else { let frame_kind = crate::scene::frame::kind_for_name(&name); @@ -1044,6 +1047,9 @@ impl OpenCADStudio { | "CENTERLTYPE" | "CENTERLTSCALE" | "CENTERLTYPEFILE" + | "CENTERCROSSSIZE" + | "CENTERCROSSGAP" + | "CENTERMARKEXE" ) { let settings = self.tabs[i].scene.centerline_settings(); let current = match name.as_str() { @@ -1052,6 +1058,9 @@ impl OpenCADStudio { "CENTERLTYPE" => settings.linetype, "CENTERLTSCALE" => settings.linetype_scale.to_string(), "CENTERLTYPEFILE" => settings.linetype_file, + "CENTERCROSSSIZE" => settings.cross_size, + "CENTERCROSSGAP" => settings.cross_gap, + "CENTERMARKEXE" => i16::from(settings.mark_extensions).to_string(), _ => unreachable!(), }; if let Some(value) = &value { diff --git a/src/app/properties.rs b/src/app/properties.rs index ae41ebb9..7331d13d 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -1844,14 +1844,16 @@ impl OpenCADStudio { &mut entity, ); - // Smart centre lines carry their own drawing-level creation style. + // Smart centre objects carry their own drawing-level creation style. // Apply it after the generic ribbon style so ordinary LINE entities // keep the existing path while centre lines honour their settings. - if acadrust::entities::CenterLineAssociation::read( + let center_line = acadrust::entities::CenterLineAssociation::read( &entity.common().extended_data, - ) - .is_some() - { + ).is_some(); + let center_mark = acadrust::entities::CenterMarkAssociation::read( + &entity.common().extended_data, + ).is_some(); + if center_line || center_mark { let settings = self.tabs[i].scene.centerline_settings(); if !settings.layer.eq_ignore_ascii_case("Current") { entity.common_mut().layer = settings.layer; @@ -1860,15 +1862,18 @@ impl OpenCADStudio { entity.common_mut().linetype = settings.linetype; } entity.common_mut().linetype_scale = settings.linetype_scale; + let application = if center_mark { + acadrust::entities::CENTERMARK_XDATA_APPLICATION + } else { + acadrust::entities::CENTERLINE_XDATA_APPLICATION + }; if !self.tabs[i] .scene .document .app_ids - .contains(acadrust::entities::CENTERLINE_XDATA_APPLICATION) + .contains(application) { - let mut app = acadrust::tables::AppId::new( - acadrust::entities::CENTERLINE_XDATA_APPLICATION, - ); + let mut app = acadrust::tables::AppId::new(application); app.handle = self.tabs[i].scene.document.allocate_handle(); let _ = self.tabs[i].scene.document.app_ids.add(app); } diff --git a/src/command.rs b/src/command.rs index 96f5e4d0..34a5446a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1206,6 +1206,12 @@ pub enum CmdResult { ReplaceMany(Vec<(Handle, Vec)>, Vec), /// Replace several entities as one undo step while keeping the command active. ReplaceManyContinue(Vec<(Handle, Vec)>), + /// Attach one smart centre mark to a newly selected circular source. + ReassociateCenterMark { + target: Handle, + source: Handle, + point: DVec3, + }, /// Cancel: discard any preview and end the command. Cancel, /// Cancel because the active drawing space changed. Cleanup is identical diff --git a/src/entities/line.rs b/src/entities/line.rs index 851082fe..1b9cf904 100644 --- a/src/entities/line.rs +++ b/src/entities/line.rs @@ -12,6 +12,62 @@ use crate::scene::model::object::{GripApply, GripDef, PropSection}; use crate::scene::model::wire_model::TangentGeom; fn to_render(line: &Line) -> RenderEntity { + if let Some(association) = + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data) + { + let mut segments = crate::scene::centermark::mark_segments(&association); + if !association.associated { + let center = crate::scene::centermark::dvec(association.center); + let directions = crate::scene::centermark::mark_directions(&association); + let size = association.cross_size.max(association.radius * 0.08).max(1.0e-6) * 0.35; + let badge_center = center + + directions[0] * (association.radius + association.extension_length + size * 2.0) + + directions[2] * size * 2.0; + let corners = [ + badge_center + directions[2] * size, + badge_center + directions[0] * size, + badge_center - directions[2] * size, + badge_center - directions[0] * size, + ]; + for index in 0..4 { + segments.push([corners[index], corners[(index + 1) % 4]]); + } + segments.push([ + badge_center + directions[2] * size * 0.45, + badge_center - directions[2] * size * 0.2, + ]); + segments.push([ + badge_center - directions[2] * size * 0.55, + badge_center - directions[2] * size * 0.65, + ]); + } + let mut points = Vec::with_capacity(segments.len() * 3); + let mut key_vertices = Vec::with_capacity(segments.len() * 2 + 1); + let mut tangent_geoms = Vec::with_capacity(segments.len()); + for (index, segment) in segments.iter().enumerate() { + if index > 0 { + points.push([f64::NAN; 3]); + } + for point in segment { + points.push([point.x, point.y, point.z]); + key_vertices.push([point.x, point.y, point.z]); + } + tangent_geoms.push(TangentGeom::Line { + p1: [segment[0].x as f32, segment[0].y as f32, segment[0].z as f32], + p2: [segment[1].x as f32, segment[1].y as f32, segment[1].z as f32], + }); + } + let center = crate::scene::centermark::dvec(association.center); + key_vertices.push([center.x, center.y, center.z]); + return RenderEntity { + pick_tris: Vec::new(), + object: RenderObject::Lines(points), + snap_pts: Vec::new(), + tangent_geoms, + key_vertices, + fill_tris: Vec::new(), + }; + } // LINE endpoints are stored in WCS — unlike the planar OCS entities // (ARC/CIRCLE/LWPOLYLINE/TEXT), the extrusion normal on a LINE only // orients its thickness sweep. Remapping the endpoints through the @@ -65,6 +121,29 @@ fn to_render(line: &Line) -> RenderEntity { } fn grips(line: &Line) -> Vec { + if let Some(association) = + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data) + { + let center = crate::scene::centermark::dvec(association.center); + let directions = crate::scene::centermark::mark_directions(&association); + let mut result = vec![center_grip(0, center)]; + if !association.show_extensions { + return result; + } + for (index, direction) in directions.iter().enumerate() { + let distance = (association.radius + association.length_adjustments[index]).max(0.0); + result.push(square_grip(index + 1, center + *direction * distance)); + } + for (index, direction) in directions.iter().enumerate() { + let distance = (association.radius + + association.extension_length + + association.length_adjustments[index] + + association.overshoots[index]) + .max(0.0); + result.push(oriented_triangle_grip(index + 5, center + *direction * distance, *direction)); + } + return result; + } let s = glam::DVec3::new(line.start.x, line.start.y, line.start.z); let e = glam::DVec3::new(line.end.x, line.end.y, line.end.z); let m = (s + e) * 0.5; @@ -88,6 +167,36 @@ fn grips(line: &Line) -> Vec { } fn properties(line: &Line) -> Vec { + if let Some(association) = + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data) + { + use crate::scene::model::object::{PropValue, Property}; + return vec![PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: "Show extension".to_owned(), + field: "centermark_show_extension", + value: PropValue::Choice { + selected: if association.show_extensions { "Yes" } else { "No" }.to_owned(), + options: vec!["Yes".to_owned(), "No".to_owned()], + }, + }, + edit("Cross size", "centermark_cross_size", association.cross_size), + edit("Cross gap", "centermark_cross_gap", association.cross_gap), + edit( + "Extension length", + "centermark_extension_length", + association.extension_length, + ), + ro( + "Associative", + "centermark_associative", + if association.associated { "Yes" } else { "No" }, + ), + ], + }]; + } if let Some(association) = acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) { @@ -136,6 +245,36 @@ fn properties(line: &Line) -> Vec { } fn apply_geom_prop(line: &mut Line, field: &str, value: &str) { + if let Some(mut association) = + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data) + { + match field { + "centermark_show_extension" => { + association.show_extensions = matches!( + value.trim().to_ascii_lowercase().as_str(), + "yes" | "on" | "true" | "1" + ); + } + "centermark_cross_size" | "centermark_cross_gap" | "centermark_extension_length" => { + let Some(number) = parse_f64(value) else { return; }; + if !number.is_finite() || number < 0.0 { return; } + match field { + "centermark_cross_size" => association.cross_size = number, + "centermark_cross_gap" => association.cross_gap = number, + "centermark_extension_length" => association.extension_length = number, + _ => unreachable!(), + } + if field == "centermark_cross_size" { + association.cross_size_relative = false; + } else if field == "centermark_cross_gap" { + association.cross_gap_relative = false; + } + } + _ => return, + } + crate::scene::centermark::update_carrier(line, &association); + return; + } let Some(v) = parse_f64(value) else { return; }; @@ -178,6 +317,37 @@ fn apply_geom_prop(line: &mut Line, field: &str, value: &str) { } fn apply_grip(line: &mut Line, grip_id: usize, apply: GripApply) { + if let Some(mut association) = + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data) + { + let center = crate::scene::centermark::dvec(association.center); + let directions = crate::scene::centermark::mark_directions(&association); + match (grip_id, apply) { + (0, GripApply::Translate(delta)) => { + let moved = center + delta; + association.center = acadrust::types::Vector3::new(moved.x, moved.y, moved.z); + let origin = crate::scene::centermark::dvec(association.plane_origin) + delta; + association.plane_origin = acadrust::types::Vector3::new(origin.x, origin.y, origin.z); + association.associated = false; + } + (id @ 1..=4, GripApply::Absolute(point)) => { + let index = id - 1; + let distance = (point - center).dot(directions[index]).max(0.0); + association.length_adjustments[index] = distance - association.radius; + } + (id @ 5..=8, GripApply::Absolute(point)) => { + let index = id - 5; + let distance = (point - center).dot(directions[index]).max(0.0); + association.overshoots[index] = distance + - association.radius + - association.extension_length + - association.length_adjustments[index]; + } + _ => return, + } + crate::scene::centermark::update_carrier(line, &association); + return; + } if let Some(mut association) = acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) { @@ -250,18 +420,10 @@ fn apply_grip(line: &mut Line, grip_id: usize, apply: GripApply) { } } -fn apply_transform(line: &mut Line, t: &EntityTransform) { - if let Some(mut association) = - acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) - { - association.associated = false; - association.write(&mut line.common.extended_data); - } +fn apply_plain_transform(line: &mut Line, t: &EntityTransform) { match t { EntityTransform::Translate(d) => { - line.translate(acadrust::types::Vector3::new( - d.x as f64, d.y as f64, d.z as f64, - )); + line.translate(acadrust::types::Vector3::new(d.x, d.y, d.z)); } EntityTransform::Rotate { center, axis, angle_rad } => { crate::scene::view::transform::apply_standard_transform(line, *center, *axis, *angle_rad); @@ -273,9 +435,7 @@ fn apply_transform(line: &mut Line, t: &EntityTransform) { acadrust::Entity::apply_transform( line, &crate::scene::view::transform::reflection_about_working_line( - *p1, - *p2, - *working_normal, + *p1, *p2, *working_normal, ), ); } @@ -285,6 +445,68 @@ fn apply_transform(line: &mut Line, t: &EntityTransform) { } } +fn apply_transform(line: &mut Line, t: &EntityTransform) { + if let Some(mut association) = + acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data) + { + if let EntityTransform::Translate(delta) = t { + let center = crate::scene::centermark::dvec(association.center) + *delta; + association.center = acadrust::types::Vector3::new(center.x, center.y, center.z); + let origin = crate::scene::centermark::dvec(association.plane_origin) + *delta; + association.plane_origin = acadrust::types::Vector3::new(origin.x, origin.y, origin.z); + association.associated = false; + crate::scene::centermark::update_carrier(line, &association); + return; + } + let center = crate::scene::centermark::dvec(association.center); + let x = crate::scene::centermark::dvec(association.plane_x).normalize_or(glam::DVec3::X); + let y = crate::scene::centermark::dvec(association.plane_y).normalize_or(glam::DVec3::Y); + let mut x_basis = Line::from_points( + acadrust::types::Vector3::new(center.x, center.y, center.z), + acadrust::types::Vector3::new(center.x + x.x, center.y + x.y, center.z + x.z), + ); + let mut y_basis = Line::from_points( + acadrust::types::Vector3::new(center.x, center.y, center.z), + acadrust::types::Vector3::new(center.x + y.x, center.y + y.y, center.z + y.z), + ); + apply_plain_transform(&mut x_basis, t); + apply_plain_transform(&mut y_basis, t); + let moved = glam::DVec3::new(x_basis.start.x, x_basis.start.y, x_basis.start.z); + let moved_x = glam::DVec3::new(x_basis.end.x, x_basis.end.y, x_basis.end.z) - moved; + let moved_y = glam::DVec3::new(y_basis.end.x, y_basis.end.y, y_basis.end.z) - moved; + let scale = ((moved_x.length() + moved_y.length()) * 0.5).max(1.0e-12); + association.center = acadrust::types::Vector3::new(moved.x, moved.y, moved.z); + association.plane_origin = association.center; + association.plane_x = acadrust::types::Vector3::new( + moved_x.normalize_or(x).x, + moved_x.normalize_or(x).y, + moved_x.normalize_or(x).z, + ); + association.plane_y = acadrust::types::Vector3::new( + moved_y.normalize_or(y).x, + moved_y.normalize_or(y).y, + moved_y.normalize_or(y).z, + ); + association.radius *= scale; + association.cross_size *= scale; + association.cross_gap *= scale; + association.extension_length *= scale; + for value in association.length_adjustments.iter_mut().chain(association.overshoots.iter_mut()) { + *value *= scale; + } + association.associated = false; + crate::scene::centermark::update_carrier(line, &association); + return; + } + if let Some(mut association) = + acadrust::entities::CenterLineAssociation::read(&line.common.extended_data) + { + association.associated = false; + association.write(&mut line.common.extended_data); + } + apply_plain_transform(line, t); +} + impl RenderConvertible for Line { fn to_render(&self, _document: &acadrust::CadDocument) -> Option { Some(to_render(self)) @@ -300,6 +522,16 @@ impl crate::entities::traits::Grippable for Line { } fn grip_menu(&self, grip_id: usize) -> Vec { use crate::scene::model::object::{GripMenuAction, GripMenuItem}; + if acadrust::entities::CenterMarkAssociation::read(&self.common.extended_data).is_some() { + return if grip_id == 0 { + vec![GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch }] + } else { + vec![ + GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch }, + GripMenuItem { label: "Lengthen", action: GripMenuAction::Lengthen }, + ] + }; + } if grip_id == 2 { vec![GripMenuItem { label: "Stretch", diff --git a/src/entities/names.rs b/src/entities/names.rs index 55deabf1..ef766de9 100644 --- a/src/entities/names.rs +++ b/src/entities/names.rs @@ -13,6 +13,8 @@ use acadrust::EntityType; pub fn ui_name(e: &EntityType) -> &'static str { match e { EntityType::Point(_) => "Point", + EntityType::Line(line) + if acadrust::entities::CenterMarkAssociation::read(&line.common.extended_data).is_some() => "Center Mark", EntityType::Line(_) => "Line", EntityType::Circle(_) => "Circle", EntityType::Arc(_) => "Arc", diff --git a/src/entities/traits.rs b/src/entities/traits.rs index ed736a43..ca716388 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -111,6 +111,14 @@ pub trait TextContent { pub fn entity_type_name(et: &EntityType) -> &str { match et { EntityType::Point(_) => "Point", + EntityType::Line(line) + if acadrust::entities::CenterMarkAssociation::read( + &line.common.extended_data, + ) + .is_some() => + { + "CenterMark" + } EntityType::Line(line) if acadrust::entities::CenterLineAssociation::read( &line.common.extended_data, diff --git a/src/modules/draw/draw/dimcenter.rs b/src/modules/draw/draw/dimcenter.rs index c725c7e6..645b9da3 100644 --- a/src/modules/draw/draw/dimcenter.rs +++ b/src/modules/draw/draw/dimcenter.rs @@ -8,22 +8,26 @@ // `m = radius * 0.2`. Both lines are committed at once and the command ends. use acadrust::types::Vector3; +use acadrust::entities::{CenterMarkAssociation, CenterMarkSource}; use acadrust::{EntityType, Handle, Line}; use glam::DVec3; use crate::t; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; +use crate::scene::centerline::{ + center_measure_is_relative, resolve_center_measure, CenterLineSettings, +}; // ── Ribbon definition ───────────────────────────────────────────────────── #[allow(dead_code)] // ribbon definition ready for wiring; command works via the command line pub fn tool() -> ToolDef { ToolDef { - id: "DIMCENTER", + id: "CENTERMARK", label: "Center Mark", icon: IconKind::Svg(include_bytes!("../../../../assets/icons/line.svg")), - event: ModuleEvent::Command("DIMCENTER".to_string()), + event: ModuleEvent::Command("CENTERMARK".to_string()), } } @@ -133,6 +137,119 @@ impl CadCommand for DimCenterCommand { } } +/// Associative centre-mark command. Each accepted pick commits one smart mark +/// and keeps the command active so further circular objects can be selected. +pub struct CenterMarkCommand { + picked: Option, + settings: CenterLineSettings, +} + +impl CenterMarkCommand { + pub(crate) fn new(settings: CenterLineSettings) -> Self { + Self { picked: None, settings } + } + + fn build_mark( + &self, + source: CenterMarkSource, + center: DVec3, + radius: f64, + x: DVec3, + y: DVec3, + ) -> EntityType { + let diameter = radius * 2.0; + let association = CenterMarkAssociation { + source, + plane_origin: Vector3::new(center.x, center.y, center.z), + plane_x: Vector3::new(x.x, x.y, x.z), + plane_y: Vector3::new(y.x, y.y, y.z), + center: Vector3::new(center.x, center.y, center.z), + radius, + cross_size: resolve_center_measure(&self.settings.cross_size, diameter, 0.1), + cross_gap: resolve_center_measure(&self.settings.cross_gap, diameter, 0.05), + cross_size_relative: center_measure_is_relative(&self.settings.cross_size), + cross_gap_relative: center_measure_is_relative(&self.settings.cross_gap), + extension_length: self.settings.extension, + length_adjustments: [0.0; 4], + overshoots: [0.0; 4], + show_extensions: self.settings.mark_extensions, + associated: true, + }; + let mut line = Line::from_points( + Vector3::new(center.x, center.y, center.z), + Vector3::new(center.x, center.y, center.z), + ); + crate::scene::centermark::update_carrier(&mut line, &association); + EntityType::Line(line) + } +} + +impl CadCommand for CenterMarkCommand { + fn name(&self) -> &'static str { "CENTERMARK" } + + fn prompt(&self) -> String { + t!("CENTERMARK Select arc or circle :").into_owned() + } + + fn needs_entity_pick(&self) -> bool { true } + fn inject_before_entity_pick(&self) -> bool { true } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + let Some((source, center, radius, x, y)) = self + .picked + .as_ref() + .and_then(|entity| crate::scene::centermark::picked_mark_source(entity, handle, point)) + else { + return CmdResult::NeedPoint; + }; + CmdResult::CommitEntity(self.build_mark(source, center, radius, x, y)) + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } +} + +pub struct CenterMarkReassociateCommand { + target: Handle, + picked: Option, +} + +impl CenterMarkReassociateCommand { + pub fn new(target: Handle) -> Self { + Self { target, picked: None } + } +} + +impl CadCommand for CenterMarkReassociateCommand { + fn name(&self) -> &'static str { "CENTERREASSOCIATE" } + fn prompt(&self) -> String { + t!("CENTERREASSOCIATE Select new arc or circle:").into_owned() + } + fn needs_entity_pick(&self) -> bool { true } + fn inject_before_entity_pick(&self) -> bool { true } + fn inject_picked_entity(&mut self, entity: EntityType) { self.picked = Some(entity); } + fn on_entity_pick(&mut self, source: Handle, point: DVec3) -> CmdResult { + let valid = self.picked.as_ref().and_then(|entity| { + crate::scene::centermark::picked_mark_source(entity, source, point) + }).is_some(); + if !valid { + return CmdResult::NeedPoint; + } + CmdResult::ReassociateCenterMark { target: self.target, source, point } + } + fn on_point(&mut self, _point: DVec3) -> CmdResult { CmdResult::NeedPoint } + fn on_enter(&mut self) -> CmdResult { CmdResult::Cancel } + fn on_escape(&mut self) -> CmdResult { CmdResult::Cancel } +} + // ── Autocomplete registry ───────────────────────────────── inventory::submit!(crate::command::CommandRegistration { names: &["DIMCENTER", "DCE", "CENTERMARK"] diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index fa1789c2..302033f9 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -63,6 +63,27 @@ pub fn explode_polyline_segments(entity: &EntityType) -> Vec { /// Returns an empty vec if the entity cannot be exploded. pub fn explode_entity(entity: &EntityType, document: &CadDocument) -> Vec { match entity { + EntityType::Line(line) => { + let Some(association) = acadrust::entities::CenterMarkAssociation::read( + &line.common.extended_data, + ) else { + return vec![]; + }; + crate::scene::centermark::mark_segments(&association) + .into_iter() + .map(|segment| { + let mut common = line.common.clone(); + common.handle = Handle::NULL; + acadrust::entities::CenterMarkAssociation::remove(&mut common.extended_data); + EntityType::Line(LineEnt { + common, + start: Vector3::new(segment[0].x, segment[0].y, segment[0].z), + end: Vector3::new(segment[1].x, segment[1].y, segment[1].z), + ..LineEnt::new() + }) + }) + .collect() + } EntityType::LwPolyline(p) => explode_lwpolyline(p), EntityType::Polyline2D(p) => explode_polyline2d(p), EntityType::Polyline(p) => explode_polyline(p), diff --git a/src/scene/centerline.rs b/src/scene/centerline.rs index 3c8d699d..78e76651 100644 --- a/src/scene/centerline.rs +++ b/src/scene/centerline.rs @@ -17,6 +17,9 @@ pub(crate) struct CenterLineSettings { pub linetype: String, pub linetype_scale: f64, pub linetype_file: String, + pub cross_size: String, + pub cross_gap: String, + pub mark_extensions: bool, } impl Default for CenterLineSettings { @@ -27,6 +30,9 @@ impl Default for CenterLineSettings { linetype: "CENTER2".to_owned(), linetype_scale: 1.0, linetype_file: String::new(), + cross_size: "0.1x".to_owned(), + cross_gap: "0.05x".to_owned(), + mark_extensions: true, } } } @@ -219,6 +225,9 @@ impl Scene { linetype: value(2).and_then(XRecordValue::as_string).unwrap_or(&defaults.linetype).to_owned(), linetype_scale: value(41).and_then(XRecordValue::as_double).unwrap_or(defaults.linetype_scale), linetype_file: value(3).and_then(XRecordValue::as_string).unwrap_or(&defaults.linetype_file).to_owned(), + cross_size: value(4).and_then(XRecordValue::as_string).unwrap_or(&defaults.cross_size).to_owned(), + cross_gap: value(5).and_then(XRecordValue::as_string).unwrap_or(&defaults.cross_gap).to_owned(), + mark_extensions: value(290).and_then(XRecordValue::as_bool).unwrap_or(defaults.mark_extensions), } } @@ -259,6 +268,27 @@ impl Scene { (41, XRecordValue::Double(number), number.to_string()) } "CENTERLTYPEFILE" => (3, XRecordValue::String(value.to_owned()), value.to_owned()), + "CENTERCROSSSIZE" | "CENTERCROSSGAP" => { + let trimmed = value.trim(); + let valid = trimmed.eq_ignore_ascii_case("ByLineType") + || trimmed.strip_suffix(['x', 'X']).is_some_and(|number| { + number.parse::().is_ok_and(|value| value.is_finite() && value > 0.0) + }) + || trimmed.parse::().is_ok_and(|value| value.is_finite() && value > 0.0); + if !valid { + return Err(format!("{name} requires a positive length, a positive x factor, or ByLineType.")); + } + let code = if name == "CENTERCROSSSIZE" { 4 } else { 5 }; + (code, XRecordValue::String(trimmed.to_owned()), trimmed.to_owned()) + } + "CENTERMARKEXE" => { + let enabled = match value.trim().to_ascii_lowercase().as_str() { + "1" | "on" | "yes" | "true" => true, + "0" | "off" | "no" | "false" => false, + _ => return Err("CENTERMARKEXE requires On/Off or 1/0.".to_owned()), + }; + (290, XRecordValue::Bool(enabled), if enabled { "1" } else { "0" }.to_owned()) + } _ => return Err(format!("Unknown centerline setting: {name}")), }; if let Some(entry) = record.entries.iter_mut().find(|entry| entry.code == code) { @@ -362,3 +392,19 @@ impl Scene { candidates.len() } } + +pub(crate) fn resolve_center_measure(specification: &str, diameter: f64, fallback_factor: f64) -> f64 { + let text = specification.trim(); + if text.eq_ignore_ascii_case("ByLineType") { + return diameter * fallback_factor; + } + if let Some(factor) = text.strip_suffix(['x', 'X']).and_then(|value| value.parse::().ok()) { + return diameter * factor.max(0.0); + } + text.parse::().unwrap_or(diameter * fallback_factor).max(0.0) +} + +pub(crate) fn center_measure_is_relative(specification: &str) -> bool { + let text = specification.trim(); + text.eq_ignore_ascii_case("ByLineType") || text.ends_with(['x', 'X']) +} diff --git a/src/scene/centermark.rs b/src/scene/centermark.rs new file mode 100644 index 00000000..bff9aba2 --- /dev/null +++ b/src/scene/centermark.rs @@ -0,0 +1,402 @@ +use super::*; +use acadrust::entities::{ + CenterMarkAssociation, CenterMarkSource, CenterMarkSourceKind, +}; +use acadrust::types::Vector3; +use cadkernel::geom2d::BulgeArc; +use glam::DVec3; + +fn vector(point: DVec3) -> Vector3 { + Vector3::new(point.x, point.y, point.z) +} + +pub(crate) fn dvec(point: Vector3) -> DVec3 { + DVec3::new(point.x, point.y, point.z) +} + +fn ocs_point(point: (f64, f64, f64), normal: Vector3) -> DVec3 { + let point = crate::scene::view::transform::ocs_point_to_wcs( + point, + (normal.x, normal.y, normal.z), + ); + DVec3::new(point.0, point.1, point.2) +} + +fn axes(normal: Vector3) -> (DVec3, DVec3) { + let (x, y) = crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)); + ( + DVec3::new(x.0, x.1, x.2), + DVec3::new(y.0, y.1, y.2), + ) +} + +fn arc_distance(arc: &BulgeArc, pick: [f64; 2]) -> f64 { + (0..=24) + .map(|index| { + let point = arc.sample(index as f64 / 24.0); + (point[0] - pick[0]).hypot(point[1] - pick[1]) + }) + .fold(f64::INFINITY, f64::min) +} + +fn segment_count(vertices: usize, closed: bool) -> usize { + if closed && vertices > 1 { + vertices + } else { + vertices.saturating_sub(1) + } +} + +/// Resolve a pick to circular source geometry, including bulged polyline arcs. +pub(crate) fn picked_mark_source( + entity: &EntityType, + handle: Handle, + pick: DVec3, +) -> Option<(CenterMarkSource, DVec3, f64, DVec3, DVec3)> { + match entity { + EntityType::Circle(circle) if circle.radius > 1.0e-10 => { + let center = ocs_point( + (circle.center.x, circle.center.y, circle.center.z), + circle.normal, + ); + let (x, y) = axes(circle.normal); + Some(( + CenterMarkSource { + handle, + kind: CenterMarkSourceKind::Circle, + segment_index: -1, + pick_point: vector(pick), + }, + center, + circle.radius, + x, + y, + )) + } + EntityType::Arc(arc) if arc.radius > 1.0e-10 => { + let center = ocs_point((arc.center.x, arc.center.y, arc.center.z), arc.normal); + let (x, y) = axes(arc.normal); + Some(( + CenterMarkSource { + handle, + kind: CenterMarkSourceKind::Arc, + segment_index: -1, + pick_point: vector(pick), + }, + center, + arc.radius, + x, + y, + )) + } + EntityType::LwPolyline(polyline) => { + let local_pick = { + let origin = ocs_point((0.0, 0.0, polyline.elevation), polyline.normal); + let (x, y) = axes(polyline.normal); + let delta = pick - origin; + [delta.dot(x), delta.dot(y)] + }; + let count = segment_count(polyline.vertices.len(), polyline.is_closed); + let (index, arc) = (0..count) + .filter_map(|index| { + let next = (index + 1) % polyline.vertices.len(); + let a = polyline.vertices[index].location; + let b = polyline.vertices[next].location; + BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], polyline.vertices[index].bulge) + .map(|arc| (index, arc)) + }) + .min_by(|(_, a), (_, b)| arc_distance(a, local_pick).total_cmp(&arc_distance(b, local_pick)))?; + let center = ocs_point((arc.center[0], arc.center[1], polyline.elevation), polyline.normal); + let (x, y) = axes(polyline.normal); + Some(( + CenterMarkSource { + handle, + kind: CenterMarkSourceKind::LwPolylineArcSegment, + segment_index: index as i32, + pick_point: vector(pick), + }, + center, + arc.radius, + x, + y, + )) + } + EntityType::Polyline2D(polyline) => { + let local_pick = { + let origin = ocs_point((0.0, 0.0, polyline.elevation), polyline.normal); + let (x, y) = axes(polyline.normal); + let delta = pick - origin; + [delta.dot(x), delta.dot(y)] + }; + let count = segment_count(polyline.vertices.len(), polyline.is_closed()); + let (index, arc) = (0..count) + .filter_map(|index| { + let next = (index + 1) % polyline.vertices.len(); + let a = polyline.vertices[index].location; + let b = polyline.vertices[next].location; + BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], polyline.vertices[index].bulge) + .map(|arc| (index, arc)) + }) + .min_by(|(_, a), (_, b)| arc_distance(a, local_pick).total_cmp(&arc_distance(b, local_pick)))?; + let center = ocs_point((arc.center[0], arc.center[1], polyline.elevation), polyline.normal); + let (x, y) = axes(polyline.normal); + Some(( + CenterMarkSource { + handle, + kind: CenterMarkSourceKind::Polyline2DArcSegment, + segment_index: index as i32, + pick_point: vector(pick), + }, + center, + arc.radius, + x, + y, + )) + } + _ => None, + } +} + +fn resolve_source( + document: &acadrust::CadDocument, + source: &CenterMarkSource, +) -> Option<(DVec3, f64, DVec3, DVec3)> { + let entity = document.get_entity(source.handle)?; + match (source.kind, entity) { + (CenterMarkSourceKind::Circle, EntityType::Circle(circle)) if circle.radius > 1.0e-10 => { + let center = ocs_point((circle.center.x, circle.center.y, circle.center.z), circle.normal); + let (x, y) = axes(circle.normal); + Some((center, circle.radius, x, y)) + } + (CenterMarkSourceKind::Arc, EntityType::Arc(arc)) if arc.radius > 1.0e-10 => { + let center = ocs_point((arc.center.x, arc.center.y, arc.center.z), arc.normal); + let (x, y) = axes(arc.normal); + Some((center, arc.radius, x, y)) + } + (CenterMarkSourceKind::LwPolylineArcSegment, EntityType::LwPolyline(polyline)) => { + let index = usize::try_from(source.segment_index).ok()?; + let count = segment_count(polyline.vertices.len(), polyline.is_closed); + if index >= count { return None; } + let next = (index + 1) % polyline.vertices.len(); + let a = polyline.vertices[index].location; + let b = polyline.vertices[next].location; + let arc = BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], polyline.vertices[index].bulge)?; + let center = ocs_point((arc.center[0], arc.center[1], polyline.elevation), polyline.normal); + let (x, y) = axes(polyline.normal); + Some((center, arc.radius, x, y)) + } + (CenterMarkSourceKind::Polyline2DArcSegment, EntityType::Polyline2D(polyline)) => { + let index = usize::try_from(source.segment_index).ok()?; + let count = segment_count(polyline.vertices.len(), polyline.is_closed()); + if index >= count { return None; } + let next = (index + 1) % polyline.vertices.len(); + let a = polyline.vertices[index].location; + let b = polyline.vertices[next].location; + let arc = BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], polyline.vertices[index].bulge)?; + let center = ocs_point((arc.center[0], arc.center[1], polyline.elevation), polyline.normal); + let (x, y) = axes(polyline.normal); + Some((center, arc.radius, x, y)) + } + _ => None, + } +} + +pub(crate) fn mark_directions(association: &CenterMarkAssociation) -> [DVec3; 4] { + let x = dvec(association.plane_x).normalize_or(DVec3::X); + let y = dvec(association.plane_y).normalize_or(DVec3::Y); + [x, -x, y, -y] +} + +pub(crate) fn mark_segments(association: &CenterMarkAssociation) -> Vec<[DVec3; 2]> { + let center = dvec(association.center); + let directions = mark_directions(association); + let half = (association.cross_size * 0.5).max(0.0); + let mut segments = vec![ + [center - directions[0] * half, center + directions[0] * half], + [center - directions[2] * half, center + directions[2] * half], + ]; + if association.show_extensions && association.radius > half + association.cross_gap { + for (index, direction) in directions.into_iter().enumerate() { + let start = center + direction * (half + association.cross_gap); + let end_distance = (association.radius + + association.extension_length + + association.length_adjustments[index] + + association.overshoots[index]) + .max(half + association.cross_gap); + segments.push([start, center + direction * end_distance]); + } + } + segments +} + +pub(crate) fn update_carrier(line: &mut acadrust::Line, association: &CenterMarkAssociation) { + let segments = mark_segments(association); + let horizontal = segments.first().copied().unwrap_or([ + dvec(association.center), + dvec(association.center), + ]); + line.start = vector(horizontal[0]); + line.end = vector(horizontal[1]); + association.write(&mut line.common.extended_data); +} + +impl Scene { + pub(crate) fn reassociate_center_mark( + &mut self, + target: Handle, + source_handle: Handle, + pick: DVec3, + ) -> bool { + let Some(source_entity) = self.document.get_entity(source_handle) else { return false; }; + let Some((source, center, radius, x, y)) = + picked_mark_source(source_entity, source_handle, pick) + else { return false; }; + let Some(EntityType::Line(target_line)) = self.document.get_entity(target) else { return false; }; + let Some(mut association) = CenterMarkAssociation::read(&target_line.common.extended_data) else { return false; }; + let old_diameter = association.radius * 2.0; + let size_factor = (old_diameter > 1.0e-12) + .then_some(association.cross_size / old_diameter) + .unwrap_or(0.1); + let gap_factor = (old_diameter > 1.0e-12) + .then_some(association.cross_gap / old_diameter) + .unwrap_or(0.05); + association.source = source; + association.center = vector(center); + association.radius = radius; + association.plane_origin = vector(center); + association.plane_x = vector(x); + association.plane_y = vector(y); + association.associated = true; + if association.cross_size_relative { + association.cross_size = radius * 2.0 * size_factor; + } + if association.cross_gap_relative { + association.cross_gap = radius * 2.0 * gap_factor; + } + if self.is_recording_undo() { + let before = self.document.get_entity_arc(target); + self.record_undo_before(target, before); + } + let Some(EntityType::Line(line)) = self.document.get_entity_mut(target) else { return false; }; + update_carrier(line, &association); + self.bump_entities(&[(target, ChangeKind::Modified)]); + true + } + + pub(crate) fn refresh_associative_center_marks( + &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 candidates: Vec<_> = self + .document + .entities() + .filter_map(|entity| { + let EntityType::Line(line) = entity else { return None; }; + let association = CenterMarkAssociation::read(&line.common.extended_data)?; + (association.associated && changed.contains(&association.source.handle)) + .then_some((line.common.handle, association)) + }) + .collect(); + let mut result = Vec::new(); + for (handle, mut association) in candidates { + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if let Some((center, radius, x, y)) = resolve_source(&self.document, &association.source) { + let old_diameter = association.radius * 2.0; + let size_factor = (old_diameter > 1.0e-12) + .then_some(association.cross_size / old_diameter) + .unwrap_or(0.1); + let gap_factor = (old_diameter > 1.0e-12) + .then_some(association.cross_gap / old_diameter) + .unwrap_or(0.05); + association.center = vector(center); + association.radius = radius; + association.plane_x = vector(x); + association.plane_y = vector(y); + if association.cross_size_relative { + association.cross_size = radius * 2.0 * size_factor; + } + if association.cross_gap_relative { + association.cross_gap = radius * 2.0 * gap_factor; + } + } else { + association.associated = false; + } + if let Some(EntityType::Line(line)) = self.document.get_entity_mut(handle) { + update_carrier(line, &association); + result.push((handle, ChangeKind::Modified)); + } + } + result + } + + pub(crate) fn reset_center_marks(&mut self, handles: &[Handle]) -> usize { + let extension = self.centerline_settings().extension; + let candidates: Vec<_> = handles + .iter() + .filter_map(|handle| { + let EntityType::Line(line) = self.document.get_entity(*handle)? else { return None; }; + let mut association = CenterMarkAssociation::read(&line.common.extended_data)?; + association.extension_length = extension; + association.overshoots = [0.0; 4]; + Some((*handle, association)) + }) + .collect(); + for (handle, association) in &candidates { + if let Some(EntityType::Line(line)) = self.document.get_entity_mut(*handle) { + update_carrier(line, association); + } + } + if !candidates.is_empty() { + let changes: Vec<_> = candidates + .iter() + .map(|(handle, _)| (*handle, ChangeKind::Modified)) + .collect(); + self.bump_entities(&changes); + } + candidates.len() + } + + pub(crate) fn set_center_mark_association( + &mut self, + handles: &[Handle], + associated: bool, + ) -> usize { + let candidates: Vec<_> = handles + .iter() + .filter_map(|handle| { + let EntityType::Line(line) = self.document.get_entity(*handle)? else { return None; }; + let mut association = CenterMarkAssociation::read(&line.common.extended_data)?; + if associated { + let (center, radius, x, y) = resolve_source(&self.document, &association.source)?; + association.center = vector(center); + association.radius = radius; + association.plane_x = vector(x); + association.plane_y = vector(y); + } + association.associated = associated; + Some((*handle, association)) + }) + .collect(); + for (handle, association) in &candidates { + if let Some(EntityType::Line(line)) = self.document.get_entity_mut(*handle) { + update_carrier(line, association); + } + } + if !candidates.is_empty() { + let changes: Vec<_> = candidates + .iter() + .map(|(handle, _)| (*handle, ChangeKind::Modified)) + .collect(); + self.bump_entities(&changes); + } + candidates.len() + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9ecb642e..8b9dbbeb 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 centermark; mod entity; mod group_layer; mod layout; @@ -2399,6 +2400,11 @@ impl Scene { changes.push(change); } } + for change in self.refresh_associative_center_marks(&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);