diff --git a/.cargo/config.toml b/.cargo/config.toml index 88c43407a..de96c3e4a 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -18,3 +18,13 @@ rustflags = [ # WebAssembly stack while developing with `trunk serve`. '-Clink-arg=-zstack-size=4194304', ] + +[target.x86_64-pc-windows-gnu] +rustflags = [ + "-Clink-arg=-Wl,--stack,16777216", +] + +[target.x86_64-pc-windows-msvc] +rustflags = [ + "-Clink-arg=/STACK:16777216", +] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 51056ad38..9264aa5a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -398,10 +398,7 @@ jobs: APP=OpenCADStudio.app rm -rf "$APP" mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" "$APP/Contents/PlugIns" - # The launcher is CFBundleExecutable (Finder always launches it, - # for both document opens and plain launches); it execs into the - # real GUI binary, renamed alongside it, over argv. See - # src/bin/ocs_launcher.rs. + # Finder opens the launcher, which relays files to the sibling GUI. cp target/aarch64-apple-darwin/release/ocs_launcher "$APP/Contents/MacOS/OpenCADStudio" cp target/aarch64-apple-darwin/release/OpenCADStudio "$APP/Contents/MacOS/OpenCADStudio-App" chmod +x "$APP/Contents/MacOS/OpenCADStudio" "$APP/Contents/MacOS/OpenCADStudio-App" diff --git a/Cargo.lock b/Cargo.lock index ff942398d..6a6a295b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -80,7 +80,7 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "acadrust" version = "0.5.4" -source = "git+https://git@github.com/HakanSeven12/cadcodec.git?rev=5b56571a190e7a17c8f12d36390d2938b0fb72f7#5b56571a190e7a17c8f12d36390d2938b0fb72f7" +source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=7b4c112bd7895fe9039514c21c5b5aa59eaf2525#7b4c112bd7895fe9039514c21c5b5aa59eaf2525" dependencies = [ "ahash 0.8.12", "anyhow", @@ -895,7 +895,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=6a5e6fc2822f6af6b2611cd605b44437b35158f0#6a5e6fc2822f6af6b2611cd605b44437b35158f0" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=8efd43cb2f04577d20c3ee33ce5273bb49affa3d#8efd43cb2f04577d20c3ee33ce5273bb49affa3d" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 0aaed3d07..a692cec04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ name = "OpenCADStudio" version = "2026.36.0" edition = "2021" build = "build.rs" +default-run = "OpenCADStudio" [workspace] members = [ @@ -32,8 +33,8 @@ glam = { version = "0.33", features = ["bytemuck"] } rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8efd43cb2f04577d20c3ee33ce5273bb49affa3d", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -74,10 +75,6 @@ objc2-foundation = { version = "0.2", features = ["NSArray", "NSURL", "NSString" iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } -[patch."https://github.com/HakanSeven12/cadcodec.git"] -acadrust = { git = "https://git@github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7" } - - [target.'cfg(not(target_arch = "wasm32"))'.dependencies] getrandom = "0.3" ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/crates/dwg-thumbnailer/src/lib.rs b/crates/dwg-thumbnailer/src/lib.rs index 736b8b0c0..28deb0399 100644 --- a/crates/dwg-thumbnailer/src/lib.rs +++ b/crates/dwg-thumbnailer/src/lib.rs @@ -259,7 +259,7 @@ pub unsafe extern "C" fn dwg_thumbnail_png( #[no_mangle] pub unsafe extern "C" fn dwg_thumbnail_free(ptr: *mut u8, len: usize) { if !ptr.is_null() && len != 0 { - drop(Box::from_raw(std::slice::from_raw_parts_mut(ptr, len))); + drop(Box::from_raw(std::ptr::slice_from_raw_parts_mut(ptr, len))); } } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 973bd0459..27ea492e4 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -14,8 +14,8 @@ bincode = "1.3" thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "5b56571a190e7a17c8f12d36390d2938b0fb72f7", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "6a5e6fc2822f6af6b2611cd605b44437b35158f0", features = ["acis", "offset"], optional = true } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "8efd43cb2f04577d20c3ee33ce5273bb49affa3d", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index b65443279..5f8cbce0f 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -450,10 +450,7 @@ fn profile_curves( b.profile_curves(profile) } -/// Validate a `Curve2Spec`'s fallible conditions BEFORE mutation (the same rules -/// the host's `curve_spec_to_entity` enforces), so `CreateMany` stays all-or-nothing. -/// Currently: ellipse minor/major ratio must be in (0, 1]. - +/// Validate that an entity exists before performing an operation. fn require_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { if b.entity_exists(id) { Ok(()) diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md index 1a8e49ebe..61bc16b05 100644 --- a/crates/ocs_plugin_api/ARCHITECTURE.md +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -219,7 +219,7 @@ Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 p The crate embeds two JSON blobs at build time: -- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). +- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for every editor record root and its dependent types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). - **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, `rustc` version, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). Both are accessible without enabling the `host` feature. diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index ac88fe336..f04715b36 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 = "c91a1c1de89a0fbc006dc360ddb0251ac8c207d1", optional = true, features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -38,7 +38,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 = "c91a1c1de89a0fbc006dc360ddb0251ac8c207d1", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index f15d41d04..e1d29878d 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -243,7 +243,7 @@ session belongs to. `ocs_plugin_api` embeds two JSON blobs at build time: - **Type registry** — a stable, language-binding-friendly schema generated by - tracing a curated allow-list of `acadrust` types with `serde-reflection`. + tracing every editor record root and its dependent types with `serde-reflection`. - **Version info** — `ocs_version`, `ocs_plugin_api_version`, `acadrust_version`, `api_version`, etc. diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index fd1dc38b9..d8ed9a7b0 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -34,11 +34,42 @@ fn generate_type_registry(out_dir: &Path) { let mut samples = Samples::new(); add_enum_samples(&mut tracer, &mut samples); - // Curated allow-list of acadrust types. We intentionally exclude - // EntityType here to avoid pulling in all 41+ entity variants and any - // custom-serializer edge cases. + // Trace the complete record roots used by the editor and MCP API. Nested + // entity/object variants and their enums are added to the same registry. type TraceFn = fn(&mut Tracer, &Samples); let types: Vec<(&str, TraceFn)> = vec![ + ("EntityType", trace::), + ("ObjectType", trace::), + ( + "HeaderVariables", + trace::, + ), + ("SummaryInfo", trace::), + ("LineType", trace::), + ("TextStyle", trace::), + ("BlockRecord", trace::), + ("DimStyle", trace::), + ("AppId", trace::), + ("View", trace::), + ("VPort", trace::), + ("Ucs", trace::), + ("VxTableRecord", trace::), + ("DxfClass", trace::), + ( + "BlockVisibilityParameter", + trace::, + ), + ("FieldDef", trace::), + ( + "DgnLsDefinition", + trace::, + ), + ("DgnLsComponent", trace::), + ( + "NotificationCollection", + trace::, + ), + ("Preview", trace::), ("Point", trace::), ("Line", trace::), ("Circle", trace::), @@ -93,10 +124,366 @@ fn generate_type_registry(out_dir: &Path) { ); } + type TraceSimpleFn = fn(&mut Tracer); + let enums: Vec<(&str, TraceSimpleFn)> = vec![ + ( + "AssocAnnotationKind", + trace_simple::, + ), + ( + "AssocConstraintNodeData", + trace_simple::, + ), + ( + "AssocEvalValue", + trace_simple::, + ), + ( + "AssocSubcurveKind", + trace_simple::, + ), + ( + "AssocSurfaceActionKind", + trace_simple::, + ), + ( + "AssocViewObjectActionParamKind", + trace_simple::, + ), + ( + "AcisVersion", + trace_simple::, + ), + ( + "AssociativeData", + trace_simple::, + ), + ( + "AttachmentPointType", + trace_simple::, + ), + ( + "BlockContentConnectionType", + trace_simple::, + ), + ( + "BlockEvalValue", + trace_simple::, + ), + ("BorderType", trace_simple::), + ( + "BoundaryEdge", + trace_simple::, + ), + ( + "BreakFlowDirection", + trace_simple::, + ), + ( + "CellAlignment", + trace_simple::, + ), + ( + "CellStyleType", + trace_simple::, + ), + ("CellType", trace_simple::), + ( + "CellValueType", + trace_simple::, + ), + ( + "ClassObjectData", + trace_simple::, + ), + ("ClipMode", trace_simple::), + ("ClipType", trace_simple::), + ( + "CompoundEntry", + trace_simple::, + ), + ( + "CompoundPropertyValue", + trace_simple::, + ), + ( + "CompoundStreamContent", + trace_simple::, + ), + ( + "DataObjectData", + trace_simple::, + ), + ( + "DgnLineStyleData", + trace_simple::, + ), + ( + "DgnLsComponentData", + trace_simple::, + ), + ( + "DgnLsComponentType", + trace_simple::, + ), + ( + "DgnLsPhaseMode", + trace_simple::, + ), + ("DimSubtype", trace_simple::), + ("Dimension", trace_simple::), + ( + "DimensionType", + trace_simple::, + ), + ( + "DynamicBlockData", + trace_simple::, + ), + ( + "EmbeddedEntity", + trace_simple::, + ), + ( + "ExtendedEntityData", + trace_simple::, + ), + ( + "FlowDirectionType", + trace_simple::, + ), + ( + "HatchPatternType", + trace_simple::, + ), + ( + "HatchStyleType", + trace_simple::, + ), + ( + "HelixConstraint", + trace_simple::, + ), + ( + "HooklineDirection", + trace_simple::, + ), + ( + "HorizontalAlignment", + trace_simple::, + ), + ( + "LeaderContentType", + trace_simple::, + ), + ( + "LeaderCreationType", + trace_simple::, + ), + ( + "LeaderDrawOrderType", + trace_simple::, + ), + ( + "LeaderPathType", + trace_simple::, + ), + ( + "LineTypeComplexContent", + trace_simple::, + ), + ( + "LegacyEntityData", + trace_simple::, + ), + ( + "MLineJustification", + trace_simple::, + ), + ("MTextFlag", trace_simple::), + ( + "MaterialProceduralValue", + trace_simple::, + ), + ( + "MultiLeaderDrawOrderType", + trace_simple::, + ), + ( + "MultiLeaderPathType", + trace_simple::, + ), + ( + "ObjectContextKind", + trace_simple::, + ), + ( + "NotificationType", + trace_simple::, + ), + ( + "OleFrameEnvelope", + trace_simple::, + ), + ( + "OleObjectType", + trace_simple::, + ), + ( + "PlotPaperUnits", + trace_simple::, + ), + ( + "PlotRotation", + trace_simple::, + ), + ("PlotType", trace_simple::), + ( + "PreviewFormat", + trace_simple::, + ), + ( + "PolyfaceSmoothType", + trace_simple::, + ), + ( + "ProxyPayloadEncoding", + trace_simple::, + ), + ( + "ResolutionUnit", + trace_simple::, + ), + ("ScaledType", trace_simple::), + ( + "SemanticPropertyValue", + trace_simple::, + ), + ( + "ShadePlotMode", + trace_simple::, + ), + ( + "ShadePlotResolutionLevel", + trace_simple::, + ), + ( + "SolidHistoryOperation", + trace_simple::, + ), + ( + "SurfaceData", + trace_simple::, + ), + ( + "SurfaceKind", + trace_simple::, + ), + ( + "SurfaceSmoothType", + trace_simple::, + ), + ( + "TableBorderType", + trace_simple::, + ), + ( + "TableCellContentType", + trace_simple::, + ), + ( + "TableFlowDirection", + trace_simple::, + ), + ( + "TextAlignmentType", + trace_simple::, + ), + ( + "TextAngleType", + trace_simple::, + ), + ( + "TextAttachmentDirectionType", + trace_simple::, + ), + ( + "TextAttachmentPointType", + trace_simple::, + ), + ( + "TextAttachmentType", + trace_simple::, + ), + ( + "TextHorizontalAlignment", + trace_simple::, + ), + ( + "TextVerticalAlignment", + trace_simple::, + ), + ( + "UnderlayType", + trace_simple::, + ), + ( + "ValueUnitType", + trace_simple::, + ), + ( + "VbaDirectoryValue", + trace_simple::, + ), + ( + "VerticalAlignment", + trace_simple::, + ), + ( + "ViewportRenderMode", + trace_simple::, + ), + ( + "VisualStylePropertyValue", + trace_simple::, + ), + ( + "ViewRepSketchGeometry", + trace_simple::, + ), + ( + "WipeoutClipMode", + trace_simple::, + ), + ( + "WipeoutClipType", + trace_simple::, + ), + ("WireType", trace_simple::), + ]; + for (name, trace) in enums { + trace(&mut tracer); + eprintln!("[ocs_plugin_api build] traced enum variants: {name}"); + } + let traced = tracer .registry() .expect("type registry tracing failed; see stderr for individual errors"); - let registry = map_to_custom_schema(&traced); + let mut registry = map_to_custom_schema(&traced); + let mut section_style_tracer = Tracer::new(TracerConfig::default()); + section_style_tracer + .trace_simple_type::() + .expect("section view style tracing failed"); + let section_style_registry = section_style_tracer + .registry() + .expect("section view style registry failed"); + let section_style = section_style_registry + .get("SectionViewStyle") + .expect("section view style registry entry"); + registry.types.insert( + TypeId::new("EntitySectionViewStyle"), + map_container("EntitySectionViewStyle", section_style), + ); let json = serde_json::to_string_pretty(®istry).unwrap(); fs::write(out_dir.join("type_registry.json"), json).unwrap(); } @@ -114,10 +501,22 @@ where } } +fn trace_simple(tracer: &mut Tracer) +where + T: DeserializeOwned, +{ + if let Err(error) = tracer.trace_simple_type::() { + panic!( + "type registry enum tracing failed for {}: {error}", + std::any::type_name::() + ); + } +} + fn add_enum_samples(tracer: &mut Tracer, samples: &mut Samples) { // serde-reflection needs at least one sample value per enum variant in // order to reconstruct the full schema. Provide samples for the enums that - // appear inside the allow-list types below. + // need concrete serialized values. let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByLayer); let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByBlock); let _ = tracer.trace_value(samples, &acadrust::LineWeight::Default); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index b43bacd8a..dfc2acbf4 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -746,10 +746,13 @@ mod repl { error: Option, error_type: Option, traceback: Option, - line_number: Option, - column_number: Option, + position: Option<(u32, u32)>, duration_ms: f64, ) -> Self { + let (line_number, column_number) = match position { + Some((l, c)) => (Some(l), Some(c)), + None => (None, None), + }; Self { success, output, diff --git a/crates/ocs_plugin_api/src/ipc/proxy.rs b/crates/ocs_plugin_api/src/ipc/proxy.rs index e7729fcd8..6b5172b08 100644 --- a/crates/ocs_plugin_api/src/ipc/proxy.rs +++ b/crates/ocs_plugin_api/src/ipc/proxy.rs @@ -261,20 +261,13 @@ pub fn run_request_proxy( token: [u8; PROXY_TOKEN_LEN], ) -> std::io::Result<()> { for stream in listener.incoming() { - let mut stream = match stream { - Ok(s) => s, - Err(e) => return Err(e), - }; + let mut stream = stream?; if !verify_token(&mut stream, &token) { continue; } let sender = Arc::clone(&sender); std::thread::spawn(move || { - loop { - let req: PluginRequest = match recv_framed(&mut stream) { - Ok(r) => r, - Err(_) => break, - }; + while let Ok(req) = recv_framed(&mut stream) { let resp = match sender.request(req) { Ok(r) => r, Err(e) => PluginResponse::Error(e.to_string()), diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index d2fc60154..05e7dd490 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -75,10 +75,12 @@ fn request_kind(req: &HostRequest) -> &'static str { } } +type DeferredRequest = (u64, Option, Box); + pub(crate) struct V4Connection { shared: Arc, incoming: Mutex>, - deferred: Mutex, Box)>>, + deferred: Mutex>, call_lock: Mutex<()>, next_id: AtomicU64, reader_handle: Mutex>>, @@ -199,7 +201,7 @@ impl V4Connection { tab_id, payload, }) => { - if tab_id.map_or(true, |request_tab| request_tab == host.tab_id()) { + if tab_id.is_none_or(|request_tab| request_tab == host.tab_id()) { self.respond_to_plugin_request( host, rid, @@ -251,7 +253,7 @@ impl V4Connection { let mut deferred = self.deferred.lock().unwrap_or_else(|e| e.into_inner()); let mut waiting = VecDeque::new(); while let Some((id, tab_id, payload)) = deferred.pop_front() { - if tab_id.map_or(true, |request_tab| request_tab == current_tab_id) { + if tab_id.is_none_or(|request_tab| request_tab == current_tab_id) { ready.push_back((id, payload)); } else { waiting.push_back((id, tab_id, payload)); @@ -271,7 +273,7 @@ impl V4Connection { tab_id, payload, }) => { - if tab_id.map_or(true, |request_tab| request_tab == current_tab_id) { + if tab_id.is_none_or(|request_tab| request_tab == current_tab_id) { self.respond_to_plugin_request( host, id, diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs index 0517d4dc5..2b5dfc794 100644 --- a/crates/ocs_plugin_api/src/type_registry.rs +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -1,17 +1,15 @@ //! Embedded type registry generated at build time. //! -//! The JSON embedded here is produced by tracing a curated allow-list of -//! `acadrust` types with `serde-reflection` and mapping the result into a -//! stable, language-binding-friendly schema defined in +//! The JSON embedded here is produced by tracing every editor record root and +//! its dependent `acadrust` types with `serde-reflection`, then mapping the +//! result into a stable, language-binding-friendly schema defined in //! [`crate::type_registry_types`]. The registry is embedded via //! `include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"))` so the //! `host` feature is not required to read it. //! -//! The allow-list is intentionally narrow: it excludes generic containers and -//! internal enums such as `EntityType` in favor of concrete serializable structs -//! like `Point`, `Line`, `Circle`, and `MText`. Language bindings and tooling -//! can depend on this schema without pulling the full `acadrust` dependency -//! tree. +//! The registry covers the complete entity, object, symbol-table, header, and +//! document-record roots used by the editor. Language bindings and tooling can +//! inspect every variant without pulling the full `acadrust` dependency tree. pub use crate::type_registry_types::*; @@ -121,13 +119,45 @@ mod tests { } #[test] - fn embedded_type_registry_does_not_contain_entity_type() { + fn embedded_type_registry_contains_every_entity_and_object_variant() { let registry: TypeRegistry = serde_json::from_str(get_embedded_type_registry_json()) .expect("valid TypeRegistry JSON"); - assert!( - !registry.types.contains_key(&TypeId::new("EntityType")), - "EntityType should not be in the allow-list registry" - ); + let entities = registry + .types + .get(&TypeId::new("EntityType")) + .expect("EntityType in complete registry"); + let objects = registry + .types + .get(&TypeId::new("ObjectType")) + .expect("ObjectType in complete registry"); + assert_eq!(entities.variants.len(), 48); + assert_eq!(objects.variants.len(), 36); + for root in [ + "HeaderVariables", + "SummaryInfo", + "LineType", + "TextStyle", + "BlockRecord", + "DimStyle", + "AppId", + "View", + "VPort", + "Ucs", + "VxTableRecord", + "DxfClass", + "BlockVisibilityParameter", + "FieldDef", + "DgnLsDefinition", + "DgnLsComponent", + "NotificationCollection", + "Preview", + "EntitySectionViewStyle", + ] { + assert!( + registry.types.contains_key(&TypeId::new(root)), + "missing {root}" + ); + } } #[test] diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index a700c08c3..9ff671577 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 = "c91a1c1de89a0fbc006dc360ddb0251ac8c207d1", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", 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 89cea10e6..888cc84eb 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 = "6dcda1a", features = ["serde"] } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "7b4c112bd7895fe9039514c21c5b5aa59eaf2525", features = ["serde"] } diff --git a/docs/automation/README.md b/docs/automation/README.md index d812c6244..1dcf4a90e 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -13,15 +13,15 @@ The client must start that command over standard input/output. If it asks for th The server provides four tools: - `ocs_sessions` finds running editor sessions and opens OpenCADStudio when none exists. -- `ocs_read` reads document state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events, and operation status. -- `ocs_execute` performs one operation or a sequential batch against the real editor. +- `ocs_read` discovers capabilities and reads document state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events, and operation status. +- `ocs_execute` performs one operation, an atomic record update, or a sequential batch against the real editor. - `ocs_capture` returns a bounded PNG of the drawing viewport or complete window. -The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape: +The normal flow is to call `ocs_sessions`, pass its returned `session_id` as `ocs_session_id` to the other tools, read the chosen session with `ocs_read`, then pass the returned document state into `ocs_execute`. For example, an undo request has this shape: ```json { - "session_id": "SESSION_FROM_OCS_SESSIONS", + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", "request": { "op": "undo", "request_id": "undo-1", @@ -31,13 +31,72 @@ The normal flow is to call `ocs_sessions`, read the chosen session with `ocs_rea } ``` +Call `ocs_read` with `op: "capabilities"` before unfamiliar work. It reports the command, geometry, transaction, capture, and database facilities supported by the running build. The `records.collections` list gives every available database collection, its record count, and whether it can be edited. + +Call `ocs_read` with `op: "record_schema"` before editing an unfamiliar record. With no parameters it lists the complete generated type registry. A collection returns the record types accepted by that collection even when the current drawing has no instance of a type. Supplying both `collection` and `type` returns the type's complete dependency graph, flattened property paths, JSON types, optional and sequence markers, enum variants, integer bounds, unambiguous unit annotations, identity fields, and write rules: + +```json +{ + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", + "op": "record_schema", + "parameters": { + "collection": "entities", + "type": "Insert" + } +} +``` + +Sequence fields include an `item_path_template` such as `/attributes/{index}`. Replace `{index}` with an index returned by `records` before using the path in `set_properties`. When a matching record exists, `runtime_schema` adds its exact JSON shape and bounded scalar examples. The embedded registry is generated from all 48 entity variants, all 36 non-graphical object variants, symbol-table records, header data, summary data, and the remaining stored record roots at build time. + +`op: "records"` returns every serializable property of entities, non-graphical objects, symbol-table records, the complete header, document metadata, and derived database views. Omitting `collection` returns the same collection manifest; use `collection: "all"` for a paged query across the complete database. Records have a stable collection plus a handle or name, a type, and `properties`. Entity records also include their display and file record type names. + +Property filters and projections use RFC 6901 JSON Pointer paths relative to `properties`. Multiple `where` filters are combined with AND. Supported operators are `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`, `starts_with`, `ends_with`, `in`, `exists`, and `not_exists`: + +```json +{ + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", + "op": "records", + "parameters": { + "collection": "entities", + "type": "Insert", + "where": [ + {"path": "/common/layer", "value": "Equipment"}, + {"path": "/attributes/0/tag", "value": "COMPANY"} + ], + "paths": ["/block_name", "/insert_point", "/attributes"] + } +} +``` + +Use `set_properties` to replace one or more fields atomically. OCS verifies the record identity, JSON types, optional compare-and-set values, layer locks, document revision, and request ID before committing one undoable edit. Handles, ownership slots, table names, and other database identity fields are read-only; their dedicated editor commands preserve cross-record references. + +```json +{ + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", + "request": { + "op": "set_properties", + "request_id": "attributes-1", + "document_id": 1, + "revision": 12, + "collection": "entities", + "handle": "2A", + "updates": [ + {"path": "/attributes/0/value", "expected": "BPH", "value": "Updated"}, + {"path": "/insert_point/z", "value": 3.0} + ] + } +} +``` + +The same operation edits entries in `objects`, every symbol table, `header`, and `summary_info`. Collections marked `mutable: false` are derived or storage metadata and remain queryable. Their source records must be changed so OCS can rebuild those views consistently. + To discover command syntax without reading application source, call `ocs_read` with `op: "commands"`. The unfiltered response lists commands and actions. Add `parameters: {"name": "PLINE"}` for a command's batch examples and interactive guidance. A complete command can be sent with `run`. Prompt answers are separated by spaces, points use `x,y` or `x,y,z`, and option answers use their displayed token: ```json { - "session_id": "SESSION_FROM_OCS_SESSIONS", + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", "request": { "op": "run", "request_id": "polyline-1", @@ -52,7 +111,7 @@ Use `batch` when the steps are already known. OCS supplies each step with the st ```json { - "session_id": "SESSION_FROM_OCS_SESSIONS", + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", "response_detail": "changed_entities", "request": { "op": "batch", @@ -67,11 +126,11 @@ Use `batch` when the steps are already known. OCS supplies each step with the st Execute responses use `response_detail: "compact"` by default and return only the state needed for the next edit. Use `changed_entities` to receive the current geometry of affected handles in the same response, or `full` when the complete editor state is needed. -`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel: +`ocs_read` query accepts exact handles, type/layer filters, field projection, world-XY bounds, nearest-curve ranking, closed-curve containment, and exact intersections between two planar curves. `detail: "full"` adds the complete serialized entity properties. Nearest points, containment, curve length, area, and intersections are calculated by the geometry kernel: ```json { - "session_id": "SESSION_FROM_OCS_SESSIONS", + "ocs_session_id": "SESSION_FROM_OCS_SESSIONS", "op": "query", "parameters": {"intersections": ["2A", "31"]} } diff --git a/docs/automation/mcp_eval.py b/docs/automation/mcp_eval.py index 956633c4f..db904bbb9 100644 --- a/docs/automation/mcp_eval.py +++ b/docs/automation/mcp_eval.py @@ -98,14 +98,14 @@ def main() -> None: if active.get("start"): client.tool( "ocs_execute", - {"session_id": session, "request": {"op": "new", "request_id": "eval-new"}}, + {"ocs_session_id": session, "request": {"op": "new", "request_id": "eval-new"}}, ) base = 1_000_000 + int(time.time()) % 100_000 draw = client.tool( "ocs_execute", { - "session_id": session, + "ocs_session_id": session, "response_detail": "changed_entities", "wait_seconds": 0, "request": { @@ -129,19 +129,19 @@ def main() -> None: crossings = client.tool( "ocs_read", - {"session_id": session, "op": "query", "parameters": {"intersections": line_handles}}, + {"ocs_session_id": session, "op": "query", "parameters": {"intersections": line_handles}}, ) assert crossings["count"] == 1 and crossings["intersections"][0]["point"] == [base, 0.0] nearest = client.tool( "ocs_read", - {"session_id": session, "op": "query", "parameters": {"near": [base + 20, 0], "handles": handles, "limit": 1}}, + {"ocs_session_id": session, "op": "query", "parameters": {"near": [base + 20, 0], "handles": handles, "limit": 1}}, ) assert nearest["entities"][0]["handle"] == circle_handles[0] measured = client.tool( "ocs_read", - {"session_id": session, "op": "measure", "parameters": {"handles": circle_handles}}, + {"ocs_session_id": session, "op": "measure", "parameters": {"handles": circle_handles}}, ) assert abs(measured["measurements"][0]["curve"]["area"] - 12.566370614359172) < 1e-9 succeeded = True @@ -151,7 +151,7 @@ def main() -> None: client.tool( "ocs_execute", { - "session_id": session, + "ocs_session_id": session, "request": { "op": "batch", "request_id": f"eval-clean-{base}", diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index e28fb6867..000911c6b 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -52,9 +52,13 @@ def legacy(server: Path) -> None: names = set(definitions) assert names == TOOLS, names execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] - assert len(execute_request["oneOf"]) == 15 + assert len(execute_request["oneOf"]) == 16 assert execute_request["properties"]["steps"]["maxItems"] == 64 assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" + assert "set_properties" in execute_request["properties"]["op"]["enum"] + assert "record_schema" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + read_parameters = definitions["ocs_read"]["inputSchema"]["properties"]["parameters"]["properties"] + assert {"collection", "where", "paths"} <= set(read_parameters) assert execute_request["properties"]["kind"]["enum"] == [ "text", "token", "point", "entity", "structure", "selection", "enter" ] @@ -110,7 +114,7 @@ def modern(server: Path) -> None: "params": { "name": "ocs_execute", "arguments": { - "session_id": "missing", + "ocs_session_id": "missing", "request": {"op": "run", "request_id": "run-1"}, }, "_meta": meta, @@ -126,7 +130,7 @@ def modern(server: Path) -> None: "method": "tools/call", "params": { "name": "ocs_execute", - "arguments": {"session_id": "missing", "request": {"op": "undo"}}, + "arguments": {"ocs_session_id": "missing", "request": {"op": "undo"}}, "_meta": meta, }, }) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index d66f73249..0a39768ce 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -777,7 +777,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: تعذر تحديد نطاق المجسم. .slice-cut-at-ax-value-kept-the-half = SLICE: تم القطع عند __ocs_fmt_0__=__ocs_fmt_1__، وأُبقي النصف __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: حدد مجسمًا واحدًا بالضبط أُنشئ في هذه الجلسة. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: لا يقطع المستوى المجسم على الجانب المحتفظ به. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: لا يقطع المستوى المجسم على الجانب المحتفظ به. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: تم تمرير منحنى عبر __ocs_fmt_0__ نقطة. .splinefit-need-at-least-3-points = SPLINEFIT: يلزم 3 نقاط على الأقل. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: حدد خطًا متعددًا لتمرير منحنى عبره. @@ -2207,7 +2220,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE محور مستوى القطع [X / Y / Z] (أضف TOP/BOTTOM بالكتابة): .slice-offset-along-x = SLICE الإزاحة على X: .slice-offset-along-y = SLICE الإزاحة على Y: - .slice-offset-along-z = SLICE الإزاحة على Z: + .slice-offset-along-z = SLICE الإزاحة على Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID حدد النقطة الأولى: .solid-specify-fourth-point = SOLID حدد النقطة الرابعة: .solid-specify-second-point = SOLID حدد النقطة الثانية: @@ -3197,7 +3230,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = إهليلجي - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 78562a4d1..6e7a791ec 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: обхватът на тялото не можа да бъде определен. .slice-cut-at-ax-value-kept-the-half = SLICE: разрязано при __ocs_fmt_0__=__ocs_fmt_1__, запазена е __ocs_fmt_2__ половина. .slice-select-exactly-one-solid-created-this-se = SLICE: избери точно едно тяло, създадено в тази сесия. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: равнината не пресича тялото откъм запазената страна. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: равнината не пресича тялото откъм запазената страна. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: сплайн прекаран през __ocs_fmt_0__ точки. .splinefit-need-at-least-3-points = SPLINEFIT: нужни са поне 3 точки. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: избери полилиния, през която да се прекара сплайн. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE ос на режещата равнина [X / Y / Z] (добави TOP/BOTTOM чрез въвеждане): .slice-offset-along-x = SLICE отместване по X: .slice-offset-along-y = SLICE отместване по Y: - .slice-offset-along-z = SLICE отместване по Z: + .slice-offset-along-z = SLICE отместване по Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Посочи първа точка: .solid-specify-fourth-point = SOLID Посочи четвърта точка: .solid-specify-second-point = SOLID Посочи втора точка: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Елиптичен - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 3dbc60ece..fe39582e9 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: Nelze určit rozsah tělesa. .slice-cut-at-ax-value-kept-the-half = SLICE: řez na __ocs_fmt_0__=__ocs_fmt_1__, zachování poloviny __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: vyberte přesně jedno těleso vytvořené v této relaci. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: rovina neprotíná těleso na držené straně. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: rovina neprotíná těleso na držené straně. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: proložení spline přes body __ocs_fmt_0__. .splinefit-need-at-least-3-points =SPLINEFIT: potřebujete alespoň 3 body. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: vyberte křivku pro proložení spline. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE osa řezné roviny [X / Y / Z] (přidejte TOP/BOTTOM zadáním): .slice-offset-along-x = Odsazení SLICE podél X: .slice-offset-along-y = Odsazení SLICE podél Y: - .slice-offset-along-z = Odsazení SLICE podél Z: + .slice-offset-along-z = Odsazení SLICE podél Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Zadejte první bod: .solid-specify-fourth-point = SOLID Zadejte čtvrtý bod: .solid-specify-second-point = SOLID Zadejte druhý bod: @@ -3182,7 +3215,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Eliptický - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 32a5a2c40..e3f676f6a 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -765,7 +765,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: konnte die Ausdehnung des Volumenkörper nicht bestimmen. .slice-cut-at-ax-value-kept-the-half = SLICE: geschnitten bei __ocs_fmt_0__=__ocs_fmt_1__, die Hälfte von __ocs_fmt_2__ beibehalten. .slice-select-exactly-one-solid-created-this-se = SLICE: Wählen Sie genau einen erstellten Volumenkörper. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Die Ebene kreuzt den Volumenkörper auf der gehaltenen Seite nicht. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Die Ebene kreuzt den Volumenkörper auf der gehaltenen Seite nicht. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: Passen Sie einen Spline durch __ocs_fmt_0__ Punkte. .splinefit-need-at-least-3-points = SPLINEFIT: mindestens 3 Punkte werden benötigt. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: Wählen Sie eine Polylinie, um eine Spline einzupassen. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE Schnittebenenachse [X / Y / Z] (fügen Sie TOP/BOTTOM durch Eingabe hinzu): .slice-offset-along-x = SLICE versetzt entlang X: .slice-offset-along-y = SLICE längs Y versetzt: - .slice-offset-along-z = SLICE Versetzt entlang Z: + .slice-offset-along-z = SLICE Versetzt entlang Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Geben Sie den ersten Punkt an: .solid-specify-fourth-point = SOLID Vierter Punkt angeben: .solid-specify-second-point = SOLID Zweiter Punkt angeben: @@ -3183,7 +3216,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptisch - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 1298bc4fa..5dba873a3 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -766,7 +766,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: δεν ήταν δυνατός ο προσδιορισμός της έκτασης του στερεού. .slice-cut-at-ax-value-kept-the-half = SLICE: τομή στο __ocs_fmt_0__=__ocs_fmt_1__, διατηρήθηκε το __ocs_fmt_2__ ήμισυ. .slice-select-exactly-one-solid-created-this-se = SLICE: επιλέξτε ακριβώς ένα στερεό που δημιουργήθηκε σε αυτή τη συνεδρία. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: το επίπεδο δεν τέμνει το στερεό στην πλευρά που διατηρείται. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: το επίπεδο δεν τέμνει το στερεό στην πλευρά που διατηρείται. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: προσαρμογή spline μέσα από __ocs_fmt_0__ σημεία. .splinefit-need-at-least-3-points = SPLINEFIT: απαιτούνται τουλάχιστον 3 σημεία. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: επιλέξτε μια πολυγραμμή για προσαρμογή spline. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE άξονας επιπέδου τομής [X / Y / Z] (προσθέστε TOP/BOTTOM πληκτρολογώντας): .slice-offset-along-x = SLICE μετατόπιση κατά X: .slice-offset-along-y = SLICE μετατόπιση κατά Y: - .slice-offset-along-z = SLICE μετατόπιση κατά Z: + .slice-offset-along-z = SLICE μετατόπιση κατά Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Ορίστε πρώτο σημείο: .solid-specify-fourth-point = SOLID Ορίστε τέταρτο σημείο: .solid-specify-second-point = SOLID Ορίστε δεύτερο σημείο: @@ -3185,7 +3218,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Ελλειπτικό - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 6c97312fe..1536065db 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -767,6 +767,19 @@ common = .slice-cut-at-ax-value-kept-the-half = SLICE: cut at __ocs_fmt_0__=__ocs_fmt_1__, kept the __ocs_fmt_2__ half. .slice-select-exactly-one-solid-created-this-se = SLICE: select exactly one solid created this session. .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: the plane does not cross the solid on the kept side. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: fit a spline through __ocs_fmt_0__ points. .splinefit-need-at-least-3-points = SPLINEFIT: need at least 3 points. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: select a polyline to fit a spline through. @@ -2196,6 +2209,26 @@ draw = .slice-offset-along-x = SLICE offset along X: .slice-offset-along-y = SLICE offset along Y: .slice-offset-along-z = SLICE offset along Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Specify first point: .solid-specify-fourth-point = SOLID Specify fourth point: .solid-specify-second-point = SOLID Specify second point: @@ -3187,6 +3220,17 @@ model = .cylinder-specify-axis-endpoint = CYLINDER Specify axis endpoint: .elliptical = Elliptical .select-a-solid-edge = Select a solid edge: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = Specify fillet radius <__ocs_fmt_0__>: .specify-chamfer-distance = Specify chamfer distance <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID Height = __ocs_fmt_0__, Width = __ocs_fmt_1__, Justification = __ocs_fmt_2__ diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 1c0f75e74..097d983ba 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -765,7 +765,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: no se pudo determinar la extensión del sólido. .slice-cut-at-ax-value-kept-the-half = SLICE: corte en __ocs_fmt_0__=__ocs_fmt_1__, se conservó la mitad __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: seleccione exactamente un sólido creado en esta sesión. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: el plano no atraviesa el sólido en el lado conservado. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: el plano no atraviesa el sólido en el lado conservado. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: se ajustó una spline por __ocs_fmt_0__ puntos. .splinefit-need-at-least-3-points = SPLINEFIT: se necesitan al menos 3 puntos. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: seleccione una polilínea por la que ajustar una spline. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE eje del plano de corte [X / Y / Z] (escriba TOP/BOTTOM para añadirlo): .slice-offset-along-x = SLICE desfase a lo largo de X: .slice-offset-along-y = SLICE desfase a lo largo de Y: - .slice-offset-along-z = SLICE desfase a lo largo de Z: + .slice-offset-along-z = SLICE desfase a lo largo de Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Precise el primer punto: .solid-specify-fourth-point = SOLID Precise el cuarto punto: .solid-specify-second-point = SOLID Precise el segundo punto: @@ -3185,7 +3218,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elíptico - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index e54f22873..a3c76dc17 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: kiinteän aineen laajuutta ei voitu määrittää. .slice-cut-at-ax-value-kept-the-half = SLICE: leikattu kohdasta __ocs_fmt_0__=__ocs_fmt_1__, __ocs_fmt_2__ puolet säilytetty. .slice-select-exactly-one-solid-created-this-se = SLICE: valitse täsmälleen yksi tämän istunnon luotu solid. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: kone ei ylitä kiinteää pintaa säilytetyllä puolella. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: kone ei ylitä kiinteää pintaa säilytetyllä puolella. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: sovita spline __ocs_fmt_0__-pisteiden läpi. .splinefit-need-at-least-3-points =SPLINEFIT: tarvitset vähintään 3 pistettä. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: valitse polyline sovittaaksesi splinin läpi. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE leikkaustason akseli [X / Y / Z] (lisää TOP/BOTTOM kirjoittamalla): .slice-offset-along-x = SLICE offset pitkin X: .slice-offset-along-y = SLICE offset pitkin Y: - .slice-offset-along-z = SLICE offset pitkin Z: + .slice-offset-along-z = SLICE offset pitkin Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Määritä ensimmäinen piste: .solid-specify-fourth-point = SOLID Määritä neljäs piste: .solid-specify-second-point = SOLID Määritä toinen piste: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptinen - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index 1666ae44b..3e0fa73e0 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -765,7 +765,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE : ne pouvait déterminer l'étendue du solide. .slice-cut-at-ax-value-kept-the-half = SLICE: coupé à __ocs_fmt_0__=__ocs_fmt_1__, maintenu la moitié __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE : sélectionnez exactement un solide créé cette session. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: le plan ne traverse pas le solide du côté retenu. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: le plan ne traverse pas le solide du côté retenu. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: adaptez une élingue à des points __ocs_fmt_0__. .splinefit-need-at-least-3-points = SPLINEFIT: nécessite au moins 3 points. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT : sélectionnez une polyligne pour l'adapter à une spline. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE axe du plan de coupe [X / Y / Z] (ajouter TOP/BOTTOM par typage): .slice-offset-along-x = SLICE décalage le long de X: .slice-offset-along-y = SLICE décalage le long de Y: - .slice-offset-along-z = SLICE décalage le long de Z: + .slice-offset-along-z = SLICE décalage le long de Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Préciser le premier point: .solid-specify-fourth-point = SOLID Préciser le quatrième point: .solid-specify-second-point = SOLID Préciser le deuxième point: @@ -3185,7 +3218,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptique - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index 1a9d1d2b2..ef77a707c 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -753,7 +753,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: ठोस की सीमा निर्धारित नहीं कर सकता है। .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__ = __ocs_fmt_1__ में कटौती, __ocs_fmt_2__ आधा रखा। .slice-select-exactly-one-solid-created-this-se = SLICE: इस सत्र को ठीक से एक ठोस बनाया गया चुनें। - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: विमान को रखा पक्ष पर ठोस पार नहीं किया जाता है। + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: विमान को रखा पक्ष पर ठोस पार नहीं किया जाता है। + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ अंक के माध्यम से एक स्पैन फिट। .splinefit-need-at-least-3-points = SPLINEFIT: कम से कम 3 अंक की आवश्यकता है। .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: एक sline के माध्यम से फिट करने के लिए एक पॉलीलाइन का चयन करें। @@ -2183,7 +2196,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE कटिंग-प्लेन अक्ष [X / Y / Z] (Typing द्वारा शीर्ष/BOTTOM को शामिल किया गया): .slice-offset-along-x = SLICE X के साथ ऑफसेट: .slice-offset-along-y = SLICE वाई के साथ ऑफसेट: - .slice-offset-along-z = SLICE Z के साथ ऑफसेट: + .slice-offset-along-z = SLICE Z के साथ ऑफसेट: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID पहली बिंदु निर्दिष्ट करें: .solid-specify-fourth-point = SOLID चौथे बिंदु को निर्दिष्ट करें: .solid-specify-second-point = SOLID दूसरे बिंदु को निर्दिष्ट करें: @@ -3173,7 +3206,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = दीर्घवृत्तीय - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index fcbc9dce5..f1d65d13b 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: nem tudta meghatározni a szilárdtest kiterjedését. .slice-cut-at-ax-value-kept-the-half = SLICE: vágás a __ocs_fmt_0__=__ocs_fmt_1__ pontnál, a __ocs_fmt_2__ felét megtartotta. .slice-select-exactly-one-solid-created-this-se = SLICE: válasszon ki pontosan egy szilárdtestet, amelyet ebben a munkamenetben hozott létre. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: a sík nem keresztezi a szilárdtestet a megtartott oldalon. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: a sík nem keresztezi a szilárdtestet a megtartott oldalon. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: illesszen be egy spline-t a __ocs_fmt_0__ pontokon. .splinefit-need-at-least-3-points =SPLINEFIT: legalább 3 pont szükséges. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: válasszon egy vonalláncot a spline átillesztéséhez. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE vágósík tengelye [X / Y / Z] (adja hozzá a TOP/BOTTOM-t beírással): .slice-offset-along-x = SLICE eltolás a X mentén: .slice-offset-along-y = SLICE eltolás a Y mentén: - .slice-offset-along-z = SLICE eltolás a Z mentén: + .slice-offset-along-z = SLICE eltolás a Z mentén: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Adja meg az első pontot: .solid-specify-fourth-point = SOLID Adja meg a negyedik pontot: .solid-specify-second-point = SOLID Második pont megadása: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptikus - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index fb5be76da..581b0a6ec 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: impossibile determinare l'estensione del solido. .slice-cut-at-ax-value-kept-the-half = SLICE: tagliato a __ocs_fmt_0__=__ocs_fmt_1__, ha mantenuto la metà __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: seleziona esattamente un solido creato in questa sessione. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: il piano non attraversa il solido dal lato mantenuto. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: il piano non attraversa il solido dal lato mantenuto. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: adatta una spline attraverso i punti __ocs_fmt_0__. .splinefit-need-at-least-3-points =SPLINEFIT: servono almeno 3 punti. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: seleziona una polilinea attraverso la quale inserire una spline. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = Asse del piano di taglio SLICE [X / Y / Z] (aggiungere TOP/BOTTOM digitando): .slice-offset-along-x = Scostamento SLICE lungo X: .slice-offset-along-y = Scostamento SLICE lungo Y: - .slice-offset-along-z = Scostamento SLICE lungo Z: + .slice-offset-along-z = Scostamento SLICE lungo Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Specificare il primo punto: .solid-specify-fourth-point = SOLID Specificare il quarto punto: .solid-specify-second-point = SOLID Specificare il secondo punto: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Ellittico - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index ef8e5b9db..f43975364 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -755,7 +755,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: ソリッドの範囲を特定できませんでした。 .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__=__ocs_fmt_1__ で切断し、__ocs_fmt_2__ 側を保持しました。 .slice-select-exactly-one-solid-created-this-se = SLICE: このセッションで作成したソリッドを 1 つ選択してください。 - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 保持側で平面がソリッドと交差しません。 + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 保持側で平面がソリッドと交差しません。 + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ 点を通るスプラインを作成しました。 .splinefit-need-at-least-3-points = SPLINEFIT: 3 点以上必要です。 .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: スプラインを通すポリラインを選択してください。 @@ -2185,7 +2198,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE 切断面の軸 [X / Y / Z](TOP/BOTTOM を追加可能): .slice-offset-along-x = SLICE X 方向オフセット: .slice-offset-along-y = SLICE Y 方向オフセット: - .slice-offset-along-z = SLICE Z 方向オフセット: + .slice-offset-along-z = SLICE Z 方向オフセット: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID 1 点目を指定: .solid-specify-fourth-point = SOLID 4 点目を指定: .solid-specify-second-point = SOLID 2 点目を指定: @@ -3173,7 +3206,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = 楕円 - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index f2924828c..f3f85d334 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: 솔리드의 범위를 확인할 수 없습니다. .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__=__ocs_fmt_1__에서 절단하고 __ocs_fmt_2__ 절반을 유지합니다. .slice-select-exactly-one-solid-created-this-se = SLICE: 이번 세션에서 생성된 정확히 하나의 솔리드를 선택합니다. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 평면이 유지된 측면의 솔리드를 교차하지 않습니다. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 평면이 유지된 측면의 솔리드를 교차하지 않습니다. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ 점을 통해 스플라인을 맞춥니다. .splinefit-need-at-least-3-points =SPLINEFIT: 최소 3개의 포인트가 필요합니다. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: 스플라인에 맞게 폴리선을 선택합니다. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE 절단 평면 축 [X / Y / Z] (입력하여 TOP/BOTTOM 추가): .slice-offset-along-x = X를 따라 SLICE 오프셋: .slice-offset-along-y = Y를 따라 SLICE 오프셋: - .slice-offset-along-z = Z를 따라 SLICE 오프셋: + .slice-offset-along-z = Z를 따라 SLICE 오프셋: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID 첫 번째 점 지정: .solid-specify-fourth-point = SOLID 네 번째 점 지정: .solid-specify-second-point = SOLID 두 번째 점 지정: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = 타원형 - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 93a868eef..ce817e236 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -763,7 +763,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: kon de omvang van de vaste stof niet bepalen. .slice-cut-at-ax-value-kept-the-half = SLICE: knippen met __ocs_fmt_0__=__ocs_fmt_1__, de helft __ocs_fmt_2__ behouden. .slice-select-exactly-one-solid-created-this-se = SLICE: selecteer precies één vaste die deze sessie aangemaakt heeft. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: het vlak kruist de vaste stof niet aan de kant van de bewaarde zijde. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: het vlak kruist de vaste stof niet aan de kant van de bewaarde zijde. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: pas een spline door __ocs_fmt_0__ punten. .splinefit-need-at-least-3-points = SPLINEFIT: minimaal 3 punten nodig. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: selecteer een polylijn om een spline door te passen. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE snijvlakas [X / Y / Z] (voeg TOP/BOTTOM toe door te typen): .slice-offset-along-x = SLICE offset langs X: .slice-offset-along-y = SLICE offset langs Y: - .slice-offset-along-z = SLICE offset langs Z: + .slice-offset-along-z = SLICE offset langs Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Geef het eerste punt op: .solid-specify-fourth-point = SOLID Geef het vierde punt op: .solid-specify-second-point = SOLID Tweede punt specificeren: @@ -2773,9 +2806,7 @@ annotate = .markup = Markering .multileader = Multiverwijslijn .multileader-draw-order = Tekenvolgorde multiverwijslijn: - .multiline-text = - Multiline - Tekst + .multiline-text = Multilijntekst .ordinate = Ordinaat .out = Uit .qdim-select-geometry-to-dimension-enter-when-d = QDIM Selecteer de te bematen geometrie (Enter om te voltooien): @@ -3142,7 +3173,7 @@ model = .revolve-entity = Rotatieobject .revolve-flags = Vlaggen omdraaien .spherical = Sferisch - .subtract = Aftrekken + .subtract = Verschil .surface-construction = Oppervlaktebouw .sweep-path = Vegen / Pad .sweep-entity = Sweepobject @@ -3154,7 +3185,7 @@ model = .u-vector-1 = U-vector 1 .u-vector-2 = U-vector 2 .uid = UID - .union = Unie + .union = Samenvoegen .v-isolines = V-isolijnen .v-vector-1 = Vector 1 .v-vector-2 = Vector 2 @@ -3185,7 +3216,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elliptisch - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ @@ -3880,7 +3922,7 @@ properties = .monochrome = Monochroom .more-colors = Meer kleuren... .multileader = Multiverwijslijn - .multiline = Multiline + .multiline = Multilijntekst .multileader-style = Multiverwijslijnstijl .multiple-lines = Meerdere regels .n-closed = N gesloten diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 28cb72201..370f2eca9 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: nie można określić zasięgu bryły. .slice-cut-at-ax-value-kept-the-half = SLICE: cięcie w __ocs_fmt_0__=__ocs_fmt_1__, zachowanie połowy __ocs_fmt_2__. .slice-select-exactly-one-solid-created-this-se = SLICE: wybierz dokładnie jedną bryłę utworzoną w tej sesji. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: płaszczyzna nie przecina bryły po zachowanej stronie. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: płaszczyzna nie przecina bryły po zachowanej stronie. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: dopasuj splajn przez punkty __ocs_fmt_0__. .splinefit-need-at-least-3-points =SPLINEFIT: potrzebne są co najmniej 3 punkty. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: wybierz polilinię, przez którą ma zostać dopasowany splajn. @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = Oś płaszczyzny cięcia SLICE [X / Y / Z] (dodaj TOP/BOTTOM wpisując): .slice-offset-along-x = Przesunięcie SLICE wzdłuż X: .slice-offset-along-y = Przesunięcie SLICE wzdłuż Y: - .slice-offset-along-z = Przesunięcie SLICE wzdłuż Z: + .slice-offset-along-z = Przesunięcie SLICE wzdłuż Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Określ pierwszy punkt: .solid-specify-fourth-point = SOLID Określ czwarty punkt: .solid-specify-second-point = SOLID Określ drugi punkt: @@ -3184,7 +3217,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Eliptyczny - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 64f5c9416..2e938bf5e 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -765,7 +765,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: não foi possível determinar a extensão do sólido. .slice-cut-at-ax-value-kept-the-half = SLICE: corte em __ocs_fmt_0__=__ocs_fmt_1__, metade __ocs_fmt_2__ mantida. .slice-select-exactly-one-solid-created-this-se = SLICE: selecione exatamente um sólido criado nesta sessão. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: o plano não atravessa o sólido no lado mantido. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: o plano não atravessa o sólido no lado mantido. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: spline ajustada através de __ocs_fmt_0__ pontos. .splinefit-need-at-least-3-points = SPLINEFIT: são necessários pelo menos 3 pontos. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: selecione uma polilinha através da qual ajustar uma spline. @@ -2195,7 +2208,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE eixo do plano de corte [X / Y / Z] (digite TOP/BOTTOM para adicionar): .slice-offset-along-x = SLICE deslocamento ao longo de X: .slice-offset-along-y = SLICE deslocamento ao longo de Y: - .slice-offset-along-z = SLICE deslocamento ao longo de Z: + .slice-offset-along-z = SLICE deslocamento ao longo de Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Especifique o primeiro ponto: .solid-specify-fourth-point = SOLID Especifique o quarto ponto: .solid-specify-second-point = SOLID Especifique o segundo ponto: @@ -3185,7 +3218,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Elíptico - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index f0b563824..f7615b132 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -768,7 +768,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE: не может определить степень твердого тела. .slice-cut-at-ax-value-kept-the-half = SLICE: вырезать на __ocs_fmt_0__=__ocs_fmt_1__, сохранить __ocs_fmt_2__ половину. .slice-select-exactly-one-solid-created-this-se = SLICE: выберите один твердотельный, созданный на этом сеансе. - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: плоскость не пересекает твердую часть на удерживаемой стороне. + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: плоскость не пересекает твердую часть на удерживаемой стороне. + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: проведите сплин через точки __ocs_fmt_0__. .splinefit-need-at-least-3-points = SPLINEFIT: необходимо не менее 3 баллов. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: выберите полилин, чтобы соответствовать сплину. @@ -2198,7 +2211,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE ось режущей плоскости [X/Y/Z] (добавить TOP/BOTTOM путем набора текста): .slice-offset-along-x = SLICE Смещение вдоль X: .slice-offset-along-y = SLICE смещение вдоль Y: - .slice-offset-along-z = SLICE Смещение вдоль Z: + .slice-offset-along-z = SLICE Смещение вдоль Z: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID Укажите первую точку: .solid-specify-fourth-point = SOLID Укажите четвертую точку: .solid-specify-second-point = SOLID Укажите вторую точку: @@ -3186,7 +3219,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Эллиптический - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 69705fee8..7e042e70d 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -754,6 +754,19 @@ common = .slice-cut-at-ax-value-kept-the-half = SLICE: __ocs_fmt_0__=__ocs_fmt_1__ konumunda kesildi, __ocs_fmt_2__ yarısı korundu. .slice-select-exactly-one-solid-created-this-se = SLICE: Bu oturumda oluşturulan tam bir katı seçin. .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: Düzlem, korunan tarafta katıyı kesmiyor. + .both = Her ikisi + .slice-select-at-least-one-target = SLICE: En az bir katı veya yüzey seçin. + .slice-invalid-plane = SLICE: Kesme düzlemi geçersiz. + .slice-side-point-on-plane = SLICE: Kesme düzleminden uzakta bir nokta seçin. + .slice-plane-misses-target = SLICE: Kesme düzlemi seçilen her nesneyi kesmiyor; hiçbir nesne değiştirilmedi. + .slice-unsupported-intersection = SLICE: Seçilen geometri desteklenmeyen bir yüzey kesişimi içeriyor; hiçbir nesne değiştirilmedi. + .slice-coincident-plane = SLICE: Kesme düzlemi kaynak geometriyle çakışıyor; hiçbir nesne değiştirilmedi. + .slice-topology-refused = SLICE: Seçilen topoloji güvenle bölünemedi; hiçbir nesne değiştirilmedi. + .slice-result-encode-failed = SLICE: Bir sonuç kodlanamadı; hiçbir nesne değiştirilmedi. + .slice-result-display-failed = SLICE: Bir sonuç eksiksiz görüntülenemedi; hiçbir nesne değiştirilmedi. + .slice-result-commit-failed = SLICE: Bir sonuç işlenemedi; tüm kaynak nesneler korundu. + .slice-retained-update-failed = SLICE: Korunan sonuç güncellenemedi. + .slice-created-results = SLICE: __ocs_fmt_0__ sonuç nesnesi oluşturuldu. .splinefit-fit-a-spline-through-n-points = SPLINEFIT: __ocs_fmt_0__ noktadan geçen bir spline uyduruldu. .splinefit-need-at-least-3-points = SPLINEFIT: En az 3 nokta gerekir. .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT: İçinden spline geçirilecek bir çoklu çizgi seçin. @@ -2177,6 +2190,26 @@ draw = .slice-offset-along-x = SLICE X boyunca ofset: .slice-offset-along-y = SLICE Y boyunca ofset: .slice-offset-along-z = SLICE Z boyunca ofset: + .slice-select-targets = SLICE Dilimlenecek katıları veya yüzeyleri seçin, ardından Enter'a basın: + .slice-first-point-or-method = SLICE Kesme düzlemindeki ilk noktayı belirtin veya [düzlemsel Nesne/Yüzey/Zekseni/Görünüm/XY/YZ/ZX/3nokta] <3nokta>: + .slice-first-plane-point = SLICE Kesme düzlemindeki ilk noktayı belirtin: + .slice-second-plane-point = SLICE Kesme düzlemindeki ikinci noktayı belirtin: + .slice-third-plane-point = SLICE Kesme düzlemindeki üçüncü noktayı belirtin: + .slice-point-on-plane = SLICE Kesme düzleminde bir nokta belirtin: + .slice-point-on-normal-axis = SLICE Düzlemin normal ekseninde bir nokta belirtin: + .slice-point-on-view-plane = SLICE Geçerli görünüm düzleminde bir nokta belirtin: + .slice-point-on-ucs-plane = SLICE Geçerli UCS düzleminde bir nokta belirtin: + .slice-select-planar-object = SLICE Düzlemsel bir eğri nesnesi seçin: + .slice-select-planar-surface = SLICE Düzlemsel bir yüzey seçin: + .slice-select-cutting-surface = SLICE Bir kesme yüzeyi seçin: + .slice-select-side-or-both = SLICE İstenen tarafta bir nokta belirtin veya [Her ikisi] : + .slice-select-object-or-both = SLICE Korunacak dilimlenmiş nesneyi seçin veya [Her ikisi] : + .slice-surface-not-planar = SLICE Seçilen yüzey düzlemsel değil. Düzlemsel bir yüzey seçin: + .slice-surface-not-supported = SLICE Seçilen yüzey desteklenen tek bir kesme yüzeyi tanımlamıyor. Başka bir yüzey seçin: + .slice-object-not-planar = SLICE Seçilen nesne bir düzlem tanımlamıyor. Düzlemsel bir eğri nesnesi seçin: + .slice-planar-object = düzlemsel Nesne + .slice-zaxis = Zekseni + .slice-three-points = 3nokta .solid-specify-first-point = SOLID İlk noktayı belirtin: .solid-specify-fourth-point = SOLID Dördüncü noktayı belirtin: .solid-specify-second-point = SOLID İkinci noktayı belirtin: @@ -3146,6 +3179,17 @@ model = .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = Eliptik .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Bir kenar seçin veya [Zincir/Döngü/Yarıçap]: + .filletedge-select-edge-chain-or-edge-radius = Bir kenar zinciri seçin veya [Kenar/Yarıçap]: + .filletedge-select-loop-edge-or-edge-chain-radius = Döngünün kenarını seçin veya [Kenar/Zincir/Yarıçap]: + .filletedge-enter-option-accept-next = Bir seçenek girin [Kabul/Sonraki] : + .filletedge-accept-or-radius = Fillet'i kabul etmek için Enter'a basın veya [Yarıçap]: + .filletedge-enter-radius-or-expression = Fillet yarıçapını girin veya [İfade] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Yarıçapı belirtin veya [İfade] <__ocs_fmt_0__>: + .filletedge-enter-expression = İfadeyi girin: + .filletedge-stale-edge = Seçilen kenar artık katıya ait değil. + .filletedge-history-create-failed = Katı geçmişi oluşturulamadı. + .filletedge-history-append-failed = Fillet geçmişi kaydedilemedi. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index caf2c55c2..324259609 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -752,7 +752,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE:无法确定固体的大小。 .slice-cut-at-ax-value-kept-the-half = SLICE: 切到__ocs_fmt_0__=__ocs_fmt_1__, 保留__ocs_fmt_2__的一半。 .slice-select-exactly-one-solid-created-this-se = SLICE:选择一个创建此会话的正体 。 - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 飞机不会越过所保存的一侧的固体。 + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 飞机不会越过所保存的一侧的固体。 + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: 匹配一条通过__ocs_fmt_0__点的相片。 .splinefit-need-at-least-3-points = SPLINEFIT:至少需要3个点。 .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT:选择一条多线以适应通过的分线。 @@ -2182,7 +2195,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE 剖切平面轴 [X / Y / Z](输入 TOP/BOTTOM 可追加): .slice-offset-along-x = SLICE 页:1 .slice-offset-along-y = SLICE 与 Y 相冲: - .slice-offset-along-z = SLICE 与 Z 相冲: + .slice-offset-along-z = SLICE 与 Z 相冲: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID 指定第一点: .solid-specify-fourth-point = SOLID 指定第四点: .solid-specify-second-point = SOLID 指定第二点: @@ -3168,7 +3201,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = 椭圆 - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index ecefead67..340ca3c9f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -764,7 +764,20 @@ common = .slice-could-not-determine-the-solid-s-extent = SLICE:無法確定固體的大小。 .slice-cut-at-ax-value-kept-the-half = SLICE: 切到__ocs_fmt_0__=__ocs_fmt_1__, 保留__ocs_fmt_2__的一半。 .slice-select-exactly-one-solid-created-this-se = SLICE:選擇一個建立此會話的正體 。 - .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 平面不會越過所儲存的一側的固體。 + .slice-the-plane-does-not-cross-the-solid-on-th = SLICE: 平面不會越過所儲存的一側的固體。 + .both = Both + .slice-select-at-least-one-target = SLICE: select at least one solid or surface. + .slice-invalid-plane = SLICE: the cutting plane is invalid. + .slice-side-point-on-plane = SLICE: choose a point away from the cutting plane. + .slice-plane-misses-target = SLICE: the cutting plane does not cross every selected object; no object was changed. + .slice-unsupported-intersection = SLICE: the selected geometry has an unsupported surface intersection; no object was changed. + .slice-coincident-plane = SLICE: the cutting plane is coincident with source geometry; no object was changed. + .slice-topology-refused = SLICE: the selected topology could not be split safely; no object was changed. + .slice-result-encode-failed = SLICE: a result could not be encoded; no object was changed. + .slice-result-display-failed = SLICE: a result could not be displayed completely; no object was changed. + .slice-result-commit-failed = SLICE: a result could not be committed; all source objects were retained. + .slice-retained-update-failed = SLICE: a retained result could not be updated. + .slice-created-results = SLICE: created __ocs_fmt_0__ result object(s). .splinefit-fit-a-spline-through-n-points = SPLINEFIT: 匹配一條通過__ocs_fmt_0__點的相片。 .splinefit-need-at-least-3-points = SPLINEFIT:至少需要3個點。 .splinefit-select-a-polyline-to-fit-a-spline-th = SPLINEFIT:選擇一條多線以適應通過的分線。 @@ -2194,7 +2207,27 @@ draw = .slice-cutting-plane-axis-x-y-z-add-top-bottom = SLICE 剖切平面軸 [X / Y / Z](輸入 TOP/BOTTOM 可追加): .slice-offset-along-x = SLICE 頁:1 .slice-offset-along-y = SLICE 與 Y 相衝: - .slice-offset-along-z = SLICE 與 Z 相衝: + .slice-offset-along-z = SLICE 與 Z 相衝: + .slice-select-targets = SLICE Select solids or surfaces to slice, then press Enter: + .slice-first-point-or-method = SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>: + .slice-first-plane-point = SLICE Specify first point on slicing plane: + .slice-second-plane-point = SLICE Specify second point on slicing plane: + .slice-third-plane-point = SLICE Specify third point on slicing plane: + .slice-point-on-plane = SLICE Specify a point on the slicing plane: + .slice-point-on-normal-axis = SLICE Specify a point on the plane's normal axis: + .slice-point-on-view-plane = SLICE Specify a point on the current view plane: + .slice-point-on-ucs-plane = SLICE Specify a point on the current UCS plane: + .slice-select-planar-object = SLICE Select a planar curve object: + .slice-select-planar-surface = SLICE Select a planar surface: + .slice-select-cutting-surface = SLICE Select a cutting surface: + .slice-select-side-or-both = SLICE Specify a point on the desired side or [Both] : + .slice-select-object-or-both = SLICE Select a sliced object to keep or [Both] : + .slice-surface-not-planar = SLICE The selected surface is not planar. Select a planar surface: + .slice-surface-not-supported = SLICE The selected surface does not define one supported cutting sheet. Select another surface: + .slice-object-not-planar = SLICE The selected object does not define a plane. Select a planar curve object: + .slice-planar-object = planar Object + .slice-zaxis = Zaxis + .slice-three-points = 3points .solid-specify-first-point = SOLID 指定第一點: .solid-specify-fourth-point = SOLID 指定第四點: .solid-specify-second-point = SOLID 指定第二點: @@ -3180,7 +3213,18 @@ model = .cylinder-specify-second-point-for-height = CYLINDER { common.specify }: { common.height } · { model.second-point }: .cylinder-specify-axis-endpoint = CYLINDER { common.specify }: { draw.axis-endpoint }: .elliptical = 橢圓 - .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .select-a-solid-edge = { draw.select }: { properties.solid } · { common.edge }: + .filletedge-select-edge-or-chain-loop-radius = Select an edge or [Chain/Loop/Radius]: + .filletedge-select-edge-chain-or-edge-radius = Select an edge chain or [Edge/Radius]: + .filletedge-select-loop-edge-or-edge-chain-radius = Select edge of loop or [Edge/Chain/Radius]: + .filletedge-enter-option-accept-next = Enter an option [Accept/Next] : + .filletedge-accept-or-radius = Press Enter to accept the fillet or [Radius]: + .filletedge-enter-radius-or-expression = Enter fillet radius or [Expression] <__ocs_fmt_0__>: + .filletedge-specify-radius-or-expression = Specify Radius or [Expression] <__ocs_fmt_0__>: + .filletedge-enter-expression = Enter expression: + .filletedge-stale-edge = A selected edge no longer belongs to the solid. + .filletedge-history-create-failed = The solid history could not be created. + .filletedge-history-append-failed = The fillet history could not be recorded. .specify-fillet-radius = { common.specify }: { common.fillet } · { common.radius } <__ocs_fmt_0__>: .specify-chamfer-distance = { common.specify }: { common.chamfer } · { common.distance } <__ocs_fmt_0__>: .polysolid-height-width-justification = POLYSOLID { common.height } = __ocs_fmt_0__, { common.width } = __ocs_fmt_1__, { ui.justification } = __ocs_fmt_2__ diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index 9240e69f6..a1cc724d6 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -103,9 +103,11 @@ if [ "$DEVELOPER_ID" = "-" ]; then # CI-parity ad-hoc signature; cannot be notarized. codesign --force --deep --sign - --timestamp=none "$APP" else - # Inside-out: the appex first (sandbox entitlement is REQUIRED for a - # QuickLook extension to run), then the outer bundle. Hardened runtime + # Sign nested code before the outer bundle. The sandbox entitlement is + # required for the QuickLook extension. Use hardened runtime # and a secure timestamp on every layer for notarization. + codesign --force --timestamp --options runtime \ + -s "$DEVELOPER_ID" "$APP/Contents/MacOS/OpenCADStudio-App" codesign --force --timestamp --options runtime \ --entitlements crates/dwg-thumbnailer/macos/entitlements.plist \ -s "$DEVELOPER_ID" "$APP/Contents/PlugIns/DWGThumbnail.appex" diff --git a/plugins/registry.json b/plugins/registry.json index 724602992..22fe274f9 100644 --- a/plugins/registry.json +++ b/plugins/registry.json @@ -7,7 +7,7 @@ { "repo": "mf4633/opencad-storm-sewer-plugin", "name": "Storm Sewer", - "description": "Gravity storm-drain network design and analysis (Rational + Manning + HGL, LandXML import)." + "description": "Gravity storm-drain network design and analysis (Rational + Manning + HGL, LandXML import). Free to draw and analyze; Pro ($29/yr) adds HTML reports, pipe sizing, multi-RP." }, { "repo": "mf4633/opencad-hydrocomplete-plugin", diff --git a/src/app/automation.rs b/src/app/automation.rs index cc3c4c748..743f05770 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -18,7 +18,7 @@ use std::io::{BufRead, Write}; use std::path::PathBuf; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use super::OpenCADStudio; @@ -138,6 +138,14 @@ fn v3(v: acadrust::types::Vector3) -> Value { json!([v.x, v.y, v.z]) } +fn entity_type_matches(entity: &acadrust::EntityType, requested: &str) -> bool { + if crate::entities::names::ui_name(entity).eq_ignore_ascii_case(requested) { + return true; + } + let record_name = crate::entities::names::dxf_name(entity); + record_name != "ENTITY" && record_name.eq_ignore_ascii_case(requested) +} + /// One entity as JSON. Summary mode carries identity only, geometry adds the /// entity's defining values, and full also includes its world bounds. fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { @@ -194,15 +202,30 @@ fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value { } E::Insert(ins) => { map.insert("block".into(), json!(ins.block_name)); + map.insert("position".into(), v3(ins.insert_point)); + let attributes: serde_json::Map = ins + .attributes + .iter() + .map(|attribute| (attribute.tag.clone(), json!(attribute.value))) + .collect(); + map.insert("attributes".into(), Value::Object(attributes)); } _ => {} } if detail == "full" { let bounds = e.as_entity().bounding_box(); - map.insert("bounds".into(), json!({ - "min":[bounds.min.x,bounds.min.y,bounds.min.z], - "max":[bounds.max.x,bounds.max.y,bounds.max.z] - })); + map.insert( + "bounds".into(), + json!({ + "min":[bounds.min.x,bounds.min.y,bounds.min.z], + "max":[bounds.max.x,bounds.max.y,bounds.max.z] + }), + ); + if let Ok(Value::Object(wrapper)) = serde_json::to_value(e) { + if let Some((_, properties)) = wrapper.into_iter().next() { + map.insert("properties".into(), properties); + } + } } obj } @@ -218,16 +241,24 @@ fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> { } fn request_handle(value: &Value) -> Option { - value.as_str() - .and_then(|value| u64::from_str_radix(value.trim_start_matches("0x"), 16).ok()) + value + .as_str() + .and_then(|value| { + let value = value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .unwrap_or(value); + u64::from_str_radix(value, 16).ok() + }) .map(acadrust::Handle::new) } fn projected_fields(mut entity: Value, fields: Option<&Vec>) -> Value { let Some(fields) = fields else { return entity }; - let Some(source) = entity.as_object_mut() else { return entity }; - let keep: std::collections::HashSet<&str> = - fields.iter().filter_map(Value::as_str).collect(); + let Some(source) = entity.as_object_mut() else { + return entity; + }; + let keep: std::collections::HashSet<&str> = fields.iter().filter_map(Value::as_str).collect(); source.retain(|key, _| key == "handle" || keep.contains(key.as_str())); entity } @@ -244,7 +275,9 @@ impl OpenCADStudio { return err(error); } if matches!(response["status"].as_str(), Some("accepted" | "running")) { - return self.control_request(json!({"op":"operation","request_id":id})).0; + return self + .control_request(json!({"op":"operation","request_id":id})) + .0; } return response; } @@ -327,7 +360,9 @@ impl OpenCADStudio { let i = self.active_tab; let before = self.tabs[i].scene.document.entities().count(); let error_revision = self.command_line.error_revision; - if let Err(error) = self.run_headless(&cmd) { return err(error); } + if let Err(error) = self.run_headless(&cmd) { + return err(error); + } if self.command_line.error_revision != error_revision { return err(self.command_line.last_error.clone().unwrap_or_default()); } @@ -342,13 +377,15 @@ impl OpenCADStudio { } "entities" => self.entity_summary(), "query" => self.entity_query(&req), + "records" => self.record_query(&req), + "record_schema" => self.record_schema(&req), + "capabilities" => self.record_capabilities(), "layers" => { let i = self.active_tab; let offset = req["offset"].as_u64().unwrap_or(0) as usize; let limit = req["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; let count = self.tabs[i].scene.document.layers.iter().count(); - let layers: Vec = self - .tabs[i] + let layers: Vec = self.tabs[i] .scene .document .layers @@ -408,8 +445,14 @@ impl OpenCADStudio { // By explicit handles (hex, as returned by `query`). if let Some(arr) = req["handles"].as_array() { for h in arr.iter().filter_map(|h| h.as_str()) { - if let Ok(v) = u64::from_str_radix(h.trim_start_matches("0x"), 16) { - self.tabs[i].scene.select_entity(acadrust::Handle::new(v), false); + let h = h + .strip_prefix("0x") + .or_else(|| h.strip_prefix("0X")) + .unwrap_or(h); + if let Ok(v) = u64::from_str_radix(h, 16) { + self.tabs[i] + .scene + .select_entity(acadrust::Handle::new(v), false); } } } @@ -421,11 +464,7 @@ impl OpenCADStudio { .scene .document .entities() - .filter(|e| { - type_filter.is_none_or(|t| { - crate::entities::names::ui_name(e).eq_ignore_ascii_case(t) - }) - }) + .filter(|e| type_filter.is_none_or(|t| entity_type_matches(e, t))) .filter(|e| layer_filter.is_none_or(|l| e.common().layer == l)) .map(|e| e.common().handle) .collect(); @@ -446,8 +485,7 @@ impl OpenCADStudio { return err("save: no \"path\" and the document has none"); }; #[cfg(not(target_arch = "wasm32"))] - let result = - self.save_tab_synchronously_protected(i, path.clone(), true); + let result = self.save_tab_synchronously_protected(i, path.clone(), true); #[cfg(target_arch = "wasm32")] let result = crate::io::save(&self.tabs[i].scene.document, &path) .map_err(crate::io::SaveFailure::other); @@ -472,19 +510,30 @@ impl OpenCADStudio { self.drive_headless_task(task) } - pub(super) fn drive_headless_task(&mut self, task: iced::Task) -> Result<(), String> { + pub(super) fn drive_headless_task( + &mut self, + task: iced::Task, + ) -> Result<(), String> { use iced::futures::StreamExt; let mut streams = Vec::new(); - if let Some(stream) = iced_runtime::task::into_stream(task) { streams.push(stream); } + if let Some(stream) = iced_runtime::task::into_stream(task) { + streams.push(stream); + } while let Some(stream) = streams.last_mut() { match iced::futures::executor::block_on(stream.next()) { Some(iced_runtime::Action::Output(message)) => { let next = self.update(message); - if let Some(stream) = iced_runtime::task::into_stream(next) { streams.push(stream); } + if let Some(stream) = iced_runtime::task::into_stream(next) { + streams.push(stream); + } } - Some(iced_runtime::Action::Widget(_)) | Some(iced_runtime::Action::Tick) | Some(iced_runtime::Action::Reload) => {}, + Some(iced_runtime::Action::Widget(_)) + | Some(iced_runtime::Action::Tick) + | Some(iced_runtime::Action::Reload) => {} Some(action) => return Err(format!("GUI runtime required for {action:?}")), - None => { streams.pop(); } + None => { + streams.pop(); + } } } self.finish_all_pending_history(); @@ -540,20 +589,36 @@ impl OpenCADStudio { let layer_filter = req["layer"].as_str(); let handles: Option> = req["handles"] .as_array() - .map(|values| values.iter().filter_map(request_handle).map(|h| h.value()).collect()) - .or_else(|| request_handle(&req["handle"]) - .map(|handle| std::iter::once(handle.value()).collect())); + .map(|values| { + values + .iter() + .filter_map(request_handle) + .map(|h| h.value()) + .collect() + }) + .or_else(|| { + request_handle(&req["handle"]) + .map(|handle| std::iter::once(handle.value()).collect()) + }); let near = request_point(req, "near"); let contains = request_point(req, "contains_point"); let bounds = req["bounds"].as_array().and_then(|values| { - (values.len() == 4).then(|| Some([ - values[0].as_f64()?, values[1].as_f64()?, - values[2].as_f64()?, values[3].as_f64()?, - ])).flatten() + (values.len() == 4) + .then(|| { + Some([ + values[0].as_f64()?, + values[1].as_f64()?, + values[2].as_f64()?, + values[3].as_f64()?, + ]) + }) + .flatten() }); - if req.get("handles").is_some() && handles.as_ref().is_some_and(|parsed| { - parsed.len() != req["handles"].as_array().map_or(0, Vec::len) - }) { + if req.get("handles").is_some() + && handles + .as_ref() + .is_some_and(|parsed| parsed.len() != req["handles"].as_array().map_or(0, Vec::len)) + { return err("query handles contains an invalid hexadecimal handle"); } if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() { @@ -565,10 +630,13 @@ impl OpenCADStudio { if req.get("contains_point").is_some() && contains.is_none() { return err("query contains_point expects two or three finite coordinates"); } - if req.get("bounds").is_some() && bounds.is_none_or(|bounds| { - !bounds.iter().all(|value| value.is_finite()) - || bounds[0] > bounds[2] || bounds[1] > bounds[3] - }) { + if req.get("bounds").is_some() + && bounds.is_none_or(|bounds| { + !bounds.iter().all(|value| value.is_finite()) + || bounds[0] > bounds[2] + || bounds[1] > bounds[3] + }) + { return err("query bounds expects finite [min_x,min_y,max_x,max_y]"); } let detail = req["detail"].as_str().unwrap_or("geometry"); @@ -580,39 +648,47 @@ impl OpenCADStudio { let mut matched = Vec::new(); for e in tab.scene.document.entities() { - if handles.as_ref().is_some_and(|handles| { - !handles.contains(&e.common().handle.value()) - }) { + if handles + .as_ref() + .is_some_and(|handles| !handles.contains(&e.common().handle.value())) + { continue; } - if type_filter.is_some_and(|value| { - !crate::entities::names::ui_name(e).eq_ignore_ascii_case(value) - }) || layer_filter.is_some_and(|value| e.common().layer != value) { + if type_filter.is_some_and(|value| !entity_type_matches(e, value)) + || layer_filter.is_some_and(|value| e.common().layer != value) + { continue; } if let Some(bounds) = bounds { let entity_bounds = e.as_entity().bounding_box(); - if entity_bounds.max.x < bounds[0] || entity_bounds.max.y < bounds[1] - || entity_bounds.min.x > bounds[2] || entity_bounds.min.y > bounds[3] + if entity_bounds.max.x < bounds[0] + || entity_bounds.max.y < bounds[1] + || entity_bounds.min.x > bounds[2] + || entity_bounds.min.y > bounds[3] { continue; } } let curve = (near.is_some() || contains.is_some()) - .then(|| crate::entities::curve::entity_curve_xy(e)).flatten(); + .then(|| crate::entities::curve::entity_curve_xy(e)) + .flatten(); if let Some(point) = contains { let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else { continue; }; if !cadkernel::geom2d::contains( - std::slice::from_ref(curve), point, + std::slice::from_ref(curve), + point, cadkernel::geom2d::Tolerance::default(), ) { continue; } } - let nearest = near.and_then(|point| curve.as_ref() - .map(|curve| cadkernel::geom2d::closest_point(curve, point))); + let nearest = near.and_then(|point| { + curve + .as_ref() + .map(|curve| cadkernel::geom2d::closest_point(curve, point)) + }); if near.is_some() && nearest.is_none() { continue; } @@ -620,20 +696,29 @@ impl OpenCADStudio { if let Some(nearest) = nearest { let object = entity.as_object_mut().expect("entity JSON object"); object.insert("distance".into(), json!(nearest.distance)); - object.insert("closest_point".into(), - json!([nearest.point[0], nearest.point[1]])); + object.insert( + "closest_point".into(), + json!([nearest.point[0], nearest.point[1]]), + ); object.insert("parameter".into(), json!(nearest.t)); } matched.push((nearest.map(|nearest| nearest.distance), entity)); } if near.is_some() { - matched.sort_by(|left, right| left.0.partial_cmp(&right.0) - .unwrap_or(std::cmp::Ordering::Equal)); + matched.sort_by(|left, right| { + left.0 + .partial_cmp(&right.0) + .unwrap_or(std::cmp::Ordering::Equal) + }); } let count = matched.len(); let fields = req["fields"].as_array(); - let entities: Vec = matched.into_iter().skip(offset).take(limit) - .map(|(_, entity)| projected_fields(entity, fields)).collect(); + let entities: Vec = matched + .into_iter() + .skip(offset) + .take(limit) + .map(|(_, entity)| projected_fields(entity, fields)) + .collect(); json!({ "ok": true, "document_id":tab.id, @@ -668,7 +753,10 @@ mod tests { fn layout_notice_skips_grid_camera_and_scene_builds() { let mut app = OpenCADStudio::new_for_test(); assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true); - assert_eq!(app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], true); + assert_eq!( + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], + true + ); let i = app.active_tab; let scene = &mut app.tabs[i].scene; scene.document.add_layout("Review").unwrap(); @@ -749,6 +837,44 @@ mod tests { assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false); } + #[test] + fn block_reference_query_exposes_instance_attributes() { + use acadrust::entities::{AttributeEntity, EntityType, Insert}; + use acadrust::types::Vector3; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let mut insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0)); + insert.common.layer = "Equipment".to_string(); + insert + .attributes + .push(AttributeEntity::simple("COMPANY", "BPH")); + insert + .attributes + .push(AttributeEntity::simple("STATUS", "ACTIVE")); + app.tabs[i].scene.add_entity(EntityType::Insert(insert)); + + let result = app + .automation_op(r#"{"op":"query","type":"Insert","layer":"Equipment","detail":"full"}"#); + assert_eq!(result["count"], 1); + assert_eq!(result["entities"][0]["type"], "Block Reference"); + assert_eq!(result["entities"][0]["block"], "A_CPT"); + assert_eq!( + result["entities"][0]["position"], + serde_json::json!([12.0, 34.0, 5.0]) + ); + assert_eq!(result["entities"][0]["attributes"]["COMPANY"], "BPH"); + assert_eq!(result["entities"][0]["attributes"]["STATUS"], "ACTIVE"); + assert_eq!( + result["entities"][0]["properties"]["attributes"][0]["value"], + "BPH" + ); + + let selected = app.automation_op(r#"{"op":"select","type":"Insert"}"#); + assert_eq!(selected["selected"], 1); + } + #[test] fn ucs_interactive_inline_args() { // `UCS Z 90` must drive the interactive UCS command step-by-step (option @@ -800,9 +926,7 @@ mod tests { fn tilted_ucs_places_planar_entities_with_the_plane_normal() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); - app.automation_op( - r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#, - ); + app.automation_op(r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#); app.automation_op(r#"{"op":"run","cmd":"CIRCLE 2,3 1"}"#); let circle = app.tabs[app.active_tab] @@ -842,7 +966,10 @@ mod tests { h.linetype_scale ); // No command should be left dangling. - assert!(app.tabs[i].active_cmd.is_none(), "command must have finished"); + assert!( + app.tabs[i].active_cmd.is_none(), + "command must have finished" + ); } #[test] @@ -1004,7 +1131,9 @@ mod tests { (20.0, 300.0), ] }; - let _ = app.update(Message::ViewportMove(iced::Point::new(path[0].0, path[0].1))); + let _ = app.update(Message::ViewportMove(iced::Point::new( + path[0].0, path[0].1, + ))); let _ = app.update(Message::ViewportLeftPress); std::thread::sleep(std::time::Duration::from_millis(180)); for &(x, y) in &path { @@ -1047,7 +1176,10 @@ mod tests { // Two-step: bare command then the value, like typing 1 + Enter // (feed_active_cmd is the same path the GUI submit offers first). let _ = app.run_command_line("PICKDRAG"); - assert!(app.tabs[app.active_tab].active_cmd.is_some(), "prompt must open"); + assert!( + app.tabs[app.active_tab].active_cmd.is_some(), + "prompt must open" + ); let _ = app.feed_active_cmd("1"); assert!(app.pick_drag_rect, "PICKDRAG 1 via the prompt must switch"); } @@ -1085,10 +1217,7 @@ mod tests { let _ = app.run_command_line("MATCHPROP"); assert!(app.tabs[i].active_cmd.is_some(), "MATCHPROP must start"); let _ = app.feed_command(StepInput::EntityPick(src_h, glam::DVec3::ZERO)); - let _ = app.feed_command(StepInput::SelectionComplete(vec![ - dst_text_h, - dst_mtext_h, - ])); + let _ = app.feed_command(StepInput::SelectionComplete(vec![dst_text_h, dst_mtext_h])); let doc = &app.tabs[i].scene.document; match doc.get_entity(dst_text_h) { @@ -1140,7 +1269,10 @@ mod tests { app.opening.is_none(), "an already-open drawing must not start a load" ); - assert!(app.pending_opens.is_empty(), "and must not queue one either"); + assert!( + app.pending_opens.is_empty(), + "and must not queue one either" + ); // The same file spelled differently (a `..` hop) is still the same file. let indirect = canon.parent().unwrap().join("..").join( @@ -1234,8 +1366,7 @@ mod tests { assert_eq!(saved["ok"], true, "{label}: {}", saved["error"]); let saved_again = app.automation_op(r#"{"op":"save"}"#); assert_eq!( - saved_again["ok"], - true, + saved_again["ok"], true, "normal save: {}", saved_again["error"] ); @@ -1253,10 +1384,8 @@ mod tests { #[test] fn save_then_open_round_trips() { let mut app = OpenCADStudio::new_for_test(); - let path = std::env::temp_dir().join(format!( - "ocs_automation_test_{}.dxf", - std::process::id() - )); + let path = + std::env::temp_dir().join(format!("ocs_automation_test_{}.dxf", std::process::id())); let _ = std::fs::remove_file(&path); let p = path.to_string_lossy().replace('\\', "\\\\"); app.automation_op(r#"{"op":"new"}"#); @@ -1321,4 +1450,69 @@ mod tests { drop(app); let _ = std::fs::remove_file(&path); } + + #[test] + fn test_pline_line_then_arc() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + { + app.tabs[0].scene.selection.borrow_mut().vp_size = (1920.0, 1080.0); + app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0); + } + + // Start PLINE + let _ = app.update(Message::CommandInput("PLINE".to_string())); + let _ = app.update(Message::CommandSubmit); + + // Click first point (100, 100) + let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 100.0))); + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + // Move to (200, 100) and click second point (draw line) + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0))); + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + let wid = app.main_window.unwrap_or_else(iced::window::Id::unique); + + // Switch to arc: Option 1 - CommandOptionPick("A") + let _ = app.update(Message::CommandOptionPick("A".to_string())); + let _ = app.view(wid); + println!( + "ENTITIES: {}", + app.tabs[0].scene.document.entities().count() + ); + println!( + "CMD: {:?}", + app.tabs[0].active_cmd.as_ref().map(|c| c.name()) + ); + + // Move mouse! + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(201.0, 100.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 150.0))); + let _ = app.view(wid); + let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 150.0))); + let _ = app.view(wid); + + // Click arc point + let _ = app.update(Message::ViewportLeftPress); + let _ = app.update(Message::ViewportLeftRelease); + + // Move mouse again + let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 160.0))); + + // Switch to line + let _ = app.update(Message::CommandOptionPick("L".to_string())); + + // Move mouse again + let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 150.0))); + + // Finish + let _ = app.update(Message::CommandOptionPick(String::new())); + } } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 8662d141a..eee1345cd 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -733,7 +733,10 @@ impl OpenCADStudio { &result, CmdResult::Relaunch(..) | CmdResult::Dispatch(..) + | CmdResult::SolidEdgeBlend { .. } | CmdResult::SolidSubtract { .. } + | CmdResult::SliceEntities { .. } + | CmdResult::SliceSurfaceEntities { .. } ); let task = self.apply_cmd_result_inner(result); let i = self.active_tab; @@ -3091,6 +3094,14 @@ impl OpenCADStudio { self.command_line.push_info(&prompt); } } + CmdResult::ReportError(msg) => { + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.command_line.push_error(&msg); + if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) { + self.command_line.push_info(&prompt); + } + } CmdResult::ReportMeasurementAndDeselect(msg) => { self.tabs[i].snap_result = None; self.tabs[i].scene.deselect_all(); @@ -4268,11 +4279,20 @@ impl OpenCADStudio { CmdResult::SolidEdgeBlend { handle, - pick, + edges, + base_face, value, + other_value, fillet, } => { - let task = self.solid_edge_blend(handle, pick, value, fillet); + let task = self.solid_edge_blend( + handle, + &edges, + base_face, + value, + other_value, + fillet, + ); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); @@ -4280,8 +4300,51 @@ impl OpenCADStudio { return task; } - CmdResult::SolidSubtract { bases, cutters } => { - let task = self.solid_subtract(&bases, &cutters); + CmdResult::SolidShell { + handle, + actions, + distance, + } => { + let task = self.solid_shell(handle, &actions, distance); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + + CmdResult::SolidSubtract { + bases, + cutters, + convert_meshes, + } => { + let task = self.solid_subtract(&bases, &cutters, convert_meshes); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + + CmdResult::SliceEntities { + targets, + plane, + keep_point, + } => { + let task = self.solid_slice(&targets, plane, keep_point); + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + return task; + } + + CmdResult::SliceSurfaceEntities { + targets, + cutter, + keep_point, + } => { + let task = self.solid_slice_surface(&targets, *cutter, keep_point); self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; self.tabs[i].scene.clear_preview_wire(); diff --git a/src/app/commands/dim.rs b/src/app/commands/dim.rs index b5c2a0eaf..8adf3b441 100644 --- a/src/app/commands/dim.rs +++ b/src/app/commands/dim.rs @@ -1,6 +1,6 @@ use super::*; -fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { +fn selected_edge_body(app: &OpenCADStudio, tab: usize) -> Option { let scene = &app.tabs.get(tab)?.scene; let selected = scene.selected_handles_in_order(); let [handle] = selected.as_slice() else { @@ -9,7 +9,7 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { if matches!( scene.document.get_entity(*handle), - Some(acadrust::EntityType::Solid3D(_)) + Some(acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_)) ) { Some(*handle) } else { @@ -17,6 +17,36 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option { } } +fn solid_edge_sources( + app: &mut OpenCADStudio, + tab: usize, +) -> Vec<(acadrust::Handle, cadkernel::brep::Body)> { + let handles = app.tabs[tab] + .scene + .document + .entities() + .filter_map(|entity| { + matches!( + entity, + acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_) + ) + .then_some(entity.common().handle) + }) + .collect::>(); + app.tabs[tab].scene.restore_solid_models(&handles); + handles + .into_iter() + .filter_map(|handle| { + app.tabs[tab] + .scene + .solid_models + .get(&handle) + .cloned() + .map(|body| (handle, body)) + }) + .collect() +} + impl OpenCADStudio { pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option> { match cmd { @@ -960,16 +990,30 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "SOLIDFILLET" => { + "FILLETEDGE" | "SOLIDFILLET" => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + let target = selected_edge_body(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Fillet, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } - "FILLET" if selected_solid(self, i).is_some() => { + "FILLET" if selected_edge_body(self, i).is_some() => { use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i)); + let target = selected_edge_body(self, i); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new( + EdgeOperation::Fillet, + target, + bodies, + crate::scene::WireModel::SELECTED, + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } @@ -1107,16 +1151,52 @@ impl OpenCADStudio { } } - "SOLIDCHAMFER" => { - use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + "CHAMFEREDGE" | "SOLIDCHAMFER" => { + use crate::modules::model::edge_cmd::SolidEdgeCommand; + let target = selected_edge_body(self, i); + let header = &self.tabs[i].scene.document.header; + let distances = (header.chamfer_distance_a, header.chamfer_distance_b); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new_chamfer( + target, + bodies, + crate::scene::WireModel::SELECTED, + distances, + ); + let distances = command.chamfer_distances(); + self.command_line.push_output( + crate::tf!( + "Distance1 = {:.4}, Distance2 = {:.4}", + distances.0, + distances.1 + ) + .as_ref(), + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } - "CHAMFER" if selected_solid(self, i).is_some() => { - use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand}; - let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i)); + "CHAMFER" if selected_edge_body(self, i).is_some() => { + use crate::modules::model::edge_cmd::SolidEdgeCommand; + let target = selected_edge_body(self, i); + let header = &self.tabs[i].scene.document.header; + let distances = (header.chamfer_distance_a, header.chamfer_distance_b); + let bodies = solid_edge_sources(self, i); + let command = SolidEdgeCommand::new_chamfer( + target, + bodies, + crate::scene::WireModel::SELECTED, + distances, + ); + let distances = command.chamfer_distances(); + self.command_line.push_output( + crate::tf!( + "Distance1 = {:.4}, Distance2 = {:.4}", + distances.0, + distances.1 + ) + .as_ref(), + ); self.command_line.push_info(&command.prompt()); self.tabs[i].active_cmd = Some(Box::new(command)); } diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 420c30bc0..42afe995a 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1082,54 +1082,95 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } + "SHELL" | "SOLIDEDIT" => { + use crate::modules::model::shell_cmd::ShellCommand; + let selected = self.tabs[i] + .scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(acadrust::EntityType::Solid3D(_)) + ) + }) + .collect::>(); + let target = (selected.len() == 1).then_some(selected[0]); + let new_cmd = if cmd == "SHELL" { + ShellCommand::direct(target) + } else { + ShellCommand::solid_edit(target) + }; + self.command_line.push_info(&new_cmd.prompt()); + self.tabs[i].active_cmd = Some(Box::new(new_cmd)); + } + // ── Solid booleans ───────────────────────────────────────────── - "UNION" | "INTERSECT" => { + "UNION" => { use crate::modules::model::boolean_cmd::BoolOp; - if let Some(op) = BoolOp::from_id(cmd) { - let solid_count = { - let scene = &self.tabs[i].scene; - scene - .selected_handles_in_order() - .into_iter() - .filter(|handle| !scene.is_layer_locked(*handle)) - .filter(|handle| { - matches!( - scene.document.get_entity(*handle), - Some(acadrust::EntityType::Solid3D(_)) - ) - }) - .take(2) - .count() - }; - if solid_count < 2 { - use crate::modules::draw::select::SelectObjectsCommand; - let selection = SelectObjectsCommand::new(cmd); - self.command_line.push_info(&selection.prompt()); - self.tabs[i].active_cmd = Some(Box::new(selection)); - } else { - return Some(self.solid_boolean(op)); - } + if self.union_ready() { + return Some(self.solid_boolean(BoolOp::Union)); + } + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::plain("UNION", "UNIONAPPLY"); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } + + "UNIONAPPLY" => { + use crate::modules::model::boolean_cmd::BoolOp; + return Some(self.solid_boolean(BoolOp::Union)); + } + + "INTERSECT" => { + use crate::modules::model::boolean_cmd::BoolOp; + if !self.intersect_ready() { + use crate::modules::draw::select::SelectObjectsCommand; + let selection = SelectObjectsCommand::new(cmd); + self.command_line.push_info(&selection.prompt()); + self.tabs[i].active_cmd = Some(Box::new(selection)); + } else { + return Some(self.solid_boolean(BoolOp::Intersect)); } } "SUBTRACT" => { use crate::modules::model::boolean_cmd::SubtractCommand; - let bases = { + let (bases, bases_have_mesh) = { let scene = &self.tabs[i].scene; - scene + let bases = scene .selected_handles_in_order() .into_iter() .filter(|handle| !scene.is_layer_locked(*handle)) .filter(|handle| { matches!( scene.document.get_entity(*handle), - Some(acadrust::EntityType::Solid3D(_)) + Some( + acadrust::EntityType::Solid3D(_) + | acadrust::EntityType::Region(_) + | acadrust::EntityType::Surface(_) + | acadrust::EntityType::Mesh(_) + | acadrust::EntityType::PolygonMesh(_) + | acadrust::EntityType::PolyfaceMesh(_) + ) ) }) - .collect() + .collect::>(); + let bases_have_mesh = bases.iter().any(|handle| { + matches!( + scene.document.get_entity(*handle), + Some( + acadrust::EntityType::Mesh(_) + | acadrust::EntityType::PolygonMesh(_) + | acadrust::EntityType::PolyfaceMesh(_) + ) + ) + }); + (bases, bases_have_mesh) }; self.tabs[i].scene.deselect_all(); - let subtract = SubtractCommand::new(bases); + let subtract = SubtractCommand::new(bases, bases_have_mesh); self.command_line.push_info(&subtract.prompt()); self.tabs[i].active_cmd = Some(Box::new(subtract)); } @@ -1180,9 +1221,9 @@ impl OpenCADStudio { // REGION — convert selected closed boundaries (closed polylines / // circles) into Region entities (one wire loop each). "REGION" | "REG" => { - use acadrust::entities::{Region, Wire}; + use acadrust::entities::Region; use acadrust::types::Vector3; - let mut loops: Vec> = Vec::new(); + let mut regions = Vec::new(); for (_, e) in self.tabs[i].scene.selected_entities().iter() { let supported = matches!( e, @@ -1190,32 +1231,39 @@ impl OpenCADStudio { if pl.is_closed && pl.vertices.len() >= 3 ) || matches!(e, acadrust::EntityType::Circle(_)); if supported { - let Some(curve) = crate::entities::curve::entity_curve(e) else { + let Some((plane, loops, true)) = + crate::scene::model::presspull_model::profile_geometry(e) + else { continue; }; - loops.push( - crate::entities::curve::curve_points(&curve) - .into_iter() - .map(|point| Vector3::new(point[0], point[1], point[2])) - .collect(), + let Some(body) = cadkernel::brep::planar_region(plane, &loops) else { + continue; + }; + let mut region = Region::new(); + region.point_of_reference = Vector3::new( + plane.origin[0], + plane.origin[1], + plane.origin[2], ); + region.common.layer = self.tabs[i].active_layer.clone(); + regions.push((region, body)); } } - if loops.is_empty() { + if regions.is_empty() { self.command_line .push_error(crate::t!("REGION: select closed polylines or circles.").as_ref()); } else { self.push_undo_snapshot(i, "REGION"); - let count = loops.len(); - for pts in loops { - let mut w = Wire::new(); - let first = pts.first().copied().unwrap_or(Vector3::new(0.0, 0.0, 0.0)); - w.points = pts; - let mut r = Region::new(); - r.point_of_reference = first; - r.wires = vec![w]; - r.common.layer = self.tabs[i].active_layer.clone(); - self.tabs[i].scene.add_entity(acadrust::EntityType::Region(r)); + let count = regions.len(); + let mut created = Vec::with_capacity(count); + for (region, body) in regions { + let handle = self.add_region_model(region, body); + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + return Some(iced::Task::none()); + } + created.push(handle); } self.tabs[i].dirty = true; self.command_line @@ -1370,21 +1418,51 @@ impl OpenCADStudio { } } - // SLICE [X|Y|Z] [TOP|BOTTOM] — cut the selected solid with an - // axis-aligned plane, keeping the lower half by default. "SLICE" | "SL" => { - use crate::command::SelectThenKeywordCommand; - let has_sel = !self.tabs[i].scene.selected_entities().is_empty(); - let c = SelectThenKeywordCommand::new( - "SLICE", - "SLICE cutting-plane axis [X / Y / Z] (add TOP/BOTTOM by typing):", - vec![ - ("X", "X", Some("SLICE offset along X:")), - ("Y", "Y", Some("SLICE offset along Y:")), - ("Z", "Z", Some("SLICE offset along Z:")), - ], - has_sel, - ); + use crate::modules::model::slice_cmd::SliceCommand; + let (targets, centre, radius, view_normal) = { + let scene = &mut self.tabs[i].scene; + let mut targets = scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + scene.document.get_entity(*handle), + Some( + acadrust::EntityType::Solid3D(_) + | acadrust::EntityType::Surface(_) + ) + ) + }) + .collect::>(); + scene.restore_solid_models(&targets); + targets.retain(|handle| scene.solid_models.contains_key(handle)); + let bounds = targets + .iter() + .filter_map(|handle| { + crate::scene::model::solid_model::extent(&scene.solid_models[handle]) + }) + .fold(None::<([f64; 3], [f64; 3])>, |bounds, (low, high)| { + Some(match bounds { + None => (low, high), + Some((mut min, mut max)) => { + for axis in 0..3 { + min[axis] = min[axis].min(low[axis]); + max[axis] = max[axis].max(high[axis]); + } + (min, max) + } + }) + }); + let (centre, radius) = bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| { + let min = glam::DVec3::from_array(min); + let max = glam::DVec3::from_array(max); + ((min + max) * 0.5, (max - min).length().max(2.0) * 0.65) + }); + (targets, centre, radius, scene.active_gaze_dir().as_dvec3()) + }; + let c = SliceCommand::new(targets, view_normal, centre, radius); self.command_line.push_info(&c.prompt()); self.tabs[i].active_cmd = Some(Box::new(c)); } @@ -1403,7 +1481,18 @@ impl OpenCADStudio { let value: Option = parts.get(val_idx).and_then(|s| s.parse().ok()); let keep_low = !parts.iter().any(|s| s == "TOP"); match value { - Some(v) => return Some(self.solid_slice(axis, v, keep_low)), + Some(v) => { + let (origin, x, normal) = match axis { + 0 => ([v, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]), + 1 => ([0.0, v, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]), + _ => ([0.0, 0.0, v], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]), + }; + let plane = cadkernel::space::Plane::orthonormal(origin, x, normal) + .expect("fixed world axes define a plane"); + let side = glam::DVec3::from_array(origin) + + glam::DVec3::from_array(normal) * if keep_low { -1.0 } else { 1.0 }; + return Some(self.slice_selected(plane, Some(side))); + } None => self.command_line.push_info( crate::t!("Usage: SLICE [X|Y|Z] [TOP|BOTTOM] (cuts the selected solid)").as_ref(), ), diff --git a/src/app/commands/fileops.rs b/src/app/commands/fileops.rs index 5b68856ea..e1237595b 100644 --- a/src/app/commands/fileops.rs +++ b/src/app/commands/fileops.rs @@ -335,9 +335,9 @@ impl OpenCADStudio { "PERSP" => return Some(Task::done(Message::SetProjection(false))), "LAYERS" => return Some(Task::done(Message::ToggleLayers)), - // SCRIPT — run a command script: each non-blank, non-comment + // SCRIPT — run a command script: each non-comment // line is fed through the same command path the `--script` startup - // flag uses, so the behaviour matches headless automation exactly. + // flag uses. Blank rows submit Enter to the active command. "SCRIPT" | "SCR" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:"); @@ -352,10 +352,8 @@ impl OpenCADStudio { let cmds: Vec> = text .lines() .map(str::trim) - .filter(|l| { - !l.is_empty() && !l.starts_with('#') && !l.starts_with(';') - }) - .map(|l| Task::done(Message::Command(l.to_string()))) + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) + .map(|l| Task::done(Message::ScriptLine(l.to_string()))) .collect(); self.command_line.push_output(crate::tf!( "SCRIPT: running {} command(s) from {p}.", diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 3f1f58b26..d6b67e7f2 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -2,7 +2,7 @@ use super::{Message, OpenCADStudio}; use crate::command::{InputKind, StepInput}; use iced::Task; -use serde_json::{json, Value}; +use serde_json::{Value, json}; use std::{collections::VecDeque, sync::OnceLock}; #[cfg(not(target_arch = "wasm32"))] mod transport; @@ -361,7 +361,7 @@ impl OpenCADStudio { "mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})), "text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial, "operation":self.control.pending.as_ref().map(|p| &p.id), - "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] + "capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"] }) } @@ -377,6 +377,9 @@ impl OpenCADStudio { | "properties" | "measure" | "query" + | "records" + | "record_schema" + | "capabilities" | "entities" | "layers" | "header" @@ -449,7 +452,9 @@ impl OpenCADStudio { return ( failure( "unknown_command", - format!("Unknown command {requested}; call commands without name to list commands"), + format!( + "Unknown command {requested}; call commands without name to list commands" + ), ), Task::none(), ); @@ -511,7 +516,7 @@ impl OpenCADStudio { "Supply a unique request_id (maximum 128 bytes)", ), Task::none(), - ) + ); } }; if let Some((_, old, result)) = self.control.completed.iter().find(|(i, _, _)| i == &id) @@ -773,6 +778,7 @@ impl OpenCADStudio { Task::none() } "property" => self.control_set_property(req)?, + "set_properties" => self.control_set_record_properties(req)?, "action" => self.control_ui_action(req)?, #[cfg(not(target_arch = "wasm32"))] "save" => { @@ -814,6 +820,12 @@ impl OpenCADStudio { }) } + pub(super) fn set_control_result(&mut self, value: Value) { + if let Some(operation) = self.control.pending.as_mut() { + operation.result = value; + } + } + pub(super) fn control_track(&mut self, task: Task) -> Task { if task.units() == 0 { return task; @@ -982,7 +994,7 @@ impl OpenCADStudio { "area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs()) }) }); - out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))})); + out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid,"moment_of_inertia":m.metrics.moment_of_inertia,"principal_directions":m.metrics.principal_directions,"principal_moments":m.metrics.principal_moments,"product_of_inertia":m.metrics.product_of_inertia,"radii_of_gyration":m.metrics.radii_of_gyration}))})); } json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out}) } @@ -1101,10 +1113,12 @@ mod tests { ); let started = request(&mut app, json!({"op":"start","cmd":"LINE"})); assert_eq!(started["status"], "waiting_input"); - assert!(started["state"]["command"]["accepts"] - .as_array() - .unwrap() - .contains(&json!("point"))); + assert!( + started["state"]["command"]["accepts"] + .as_array() + .unwrap() + .contains(&json!("point")) + ); assert_eq!( started["state"]["command"]["input_example"]["kind"], "point" @@ -1129,10 +1143,12 @@ mod tests { ); assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1); request(&mut app, json!({"op":"select","type":"LINE"})); - assert!(!app.control_properties()["sections"] - .as_array() - .unwrap() - .is_empty()); + assert!( + !app.control_properties()["sections"] + .as_array() + .unwrap() + .is_empty() + ); let changed = request(&mut app, json!({"op":"property","field":"color","value":1})); assert_eq!(changed["ok"], true, "{changed}"); assert_eq!( @@ -1222,6 +1238,92 @@ mod tests { assert!((curve["length"].as_f64().unwrap() - std::f64::consts::TAU * 2.0).abs() < 1e-9); assert!((curve["area"].as_f64().unwrap() - std::f64::consts::PI * 4.0).abs() < 1e-9); } + + #[test] + fn control_shell_roundtrips_with_kernel_mass_properties() { + let mut app = OpenCADStudio::new_for_test(); + request(&mut app, json!({"op":"new"})); + request(&mut app, json!({"op":"start","cmd":"BOX"})); + request( + &mut app, + json!({"op":"input","kind":"point","point":[0.0,0.0,0.0]}), + ); + request( + &mut app, + json!({"op":"input","kind":"point","point":[6.0,5.0,0.0]}), + ); + assert_eq!( + request(&mut app, json!({"op":"input","kind":"token","text":"4"}))["status"], + "completed" + ); + let handle = app.tabs[app.active_tab] + .scene + .document + .entities() + .find_map(|entity| { + matches!(entity, acadrust::EntityType::Solid3D(_)).then(|| entity.common().handle) + }) + .unwrap(); + let handle_text = format!("{:X}", handle.value()); + + request(&mut app, json!({"op":"select","handles":[handle_text]})); + request(&mut app, json!({"op":"start","cmd":"SHELL"})); + request( + &mut app, + json!({"op":"input","kind":"entity","handle":handle_text,"point":[3.0,2.5,4.0]}), + ); + request(&mut app, json!({"op":"input","kind":"enter"})); + assert_eq!( + request(&mut app, json!({"op":"input","kind":"token","text":"0.5"}))["status"], + "completed" + ); + + let measured = app.control_measure(&json!({"handles":[handle_text]})); + let mesh = &measured["measurements"][0]["mesh"]; + assert!((mesh["volume"].as_f64().unwrap() - 50.0).abs() < 1e-9); + assert_eq!(mesh["principal_moments"].as_array().unwrap().len(), 3); + assert!(matches!( + app.tabs[app.active_tab] + .scene + .document + .solid_history_operation(handle), + Some(acadrust::objects::SolidHistoryOperation::Brep(_)) + )); + + assert_eq!(request(&mut app, json!({"op":"undo"}))["ok"], true); + let restored = app.control_measure(&json!({"handles":[handle_text]})); + assert!( + (restored["measurements"][0]["mesh"]["volume"] + .as_f64() + .unwrap() + - 120.0) + .abs() + < 1e-9 + ); + assert_eq!(request(&mut app, json!({"op":"redo"}))["ok"], true); + + let path = std::env::temp_dir().join(format!("ocs-shell-{}.dwg", session_id())); + assert_eq!( + request(&mut app, json!({"op":"save","path":path}))["ok"], + true + ); + request(&mut app, json!({"op":"new"})); + assert_eq!( + request(&mut app, json!({"op":"open","path":path}))["status"], + "completed" + ); + let reopened = app.control_measure(&json!({"handles":[handle_text]})); + assert!( + (reopened["measurements"][0]["mesh"]["volume"] + .as_f64() + .unwrap() + - 50.0) + .abs() + < 1e-9 + ); + let _ = std::fs::remove_file(path); + } + #[test] fn control_retry_is_idempotent_and_stale_state_rejected() { let mut app = OpenCADStudio::new_for_test(); diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index 360daa23a..d749e4a6c 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -999,14 +999,6 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { } } -/// Signed volume and centroid of a closed triangle mesh via the divergence -/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source -/// of truth for both (used by the cold-cache `volume`/`centroid` fallback). -fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) { - // Delegate to the crate's single source of truth (divergence theorem). - ocs_doc_api::geom::mesh_volume_centroid(mesh) -} - #[cfg(test)] mod tests { use super::*; @@ -3235,7 +3227,7 @@ mod tests { let body = crate::scene::convert::solid3d_tess::kernel_body(solid) .expect("re-lift reloaded solid failed"); let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3); - let vol = mesh_volume_centroid(&mesh).0; + let vol = ocs_doc_api::geom::mesh_volume_centroid(&mesh).0; assert!( (vol - 125.0).abs() < 1.0, "reloaded intersection volume {vol}" diff --git a/src/app/mod.rs b/src/app/mod.rs index abdffb8e7..8dbe6b889 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,21 +9,22 @@ pub(crate) mod config; pub use automation::{export_headless, serve}; mod command_driver; pub(crate) mod commands; +#[cfg(not(target_arch = "wasm32"))] +mod doc_api; mod document; pub(crate) mod expr_eval; mod find_replace; pub(crate) mod helpers; mod history; -#[cfg(not(target_arch = "wasm32"))] -mod doc_api; mod layers; mod model_ops; mod mtext_editor; #[cfg(not(target_arch = "wasm32"))] pub mod plugin_host; -mod properties; mod presspull_ops; +mod properties; mod recent; +mod record_api; pub(crate) mod settings; mod shortcuts; mod startup; @@ -225,7 +226,10 @@ impl From<&QSelectState> for QSelectSettings { Self { scope: state.scope, type_filter: state.type_filter.clone(), - property_field: state.property.as_ref().map(|property| property.field.clone()), + property_field: state + .property + .as_ref() + .map(|property| property.field.clone()), operator: state.operator, value: state.value.clone(), mode: state.mode, @@ -256,12 +260,12 @@ pub(crate) enum FindMatchKey { } use crate::snap::Snapper; use crate::ui::{CommandLine, Ribbon, StatusBar}; -use acadrust::types::{Color as AcadColor, LineWeight}; use acadrust::CadDocument; +use acadrust::types::{Color as AcadColor, LineWeight}; use iced::time::Instant; use iced::window; -use iced::{mouse, Point, Task, Theme}; +use iced::{Point, Task, Theme, mouse}; use std::sync::Arc; pub(super) const POLY_START_DELAY_MS: u128 = 150; @@ -318,9 +322,7 @@ struct AddSelectedRestore { } /// Which Start-page section a narrow (tabbed) Start page is showing. -#[derive( - Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize, -)] +#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize)] pub enum StartSection { Recent, Videos, @@ -780,15 +782,13 @@ pub(super) struct OpenCADStudio { /// Live filter for installed and available plugin cards. plugin_search_input: String, /// Installable release tags fetched per linked repo (for the dropdown). - repo_release_tags: - rustc_hash::FxHashMap>, + repo_release_tags: rustc_hash::FxHashMap>, /// The release tag currently selected per linked repo. repo_selected_tag: rustc_hash::FxHashMap, /// Repository currently shown in the Plugin Manager detail pane. selected_plugin_repo: Option, /// Parsed GitHub README content or the last fetch error, cached per repo. - plugin_readmes: - rustc_hash::FxHashMap>, + plugin_readmes: rustc_hash::FxHashMap>, /// README requests in flight, used to render a deterministic loading state. plugin_readme_loading: rustc_hash::FxHashSet, /// Last marketplace status / error line shown in the Plugin Manager. @@ -1098,8 +1098,7 @@ pub(super) struct OpenCADStudio { save_job_serial: u64, /// Destination leases held while Save As workers are active. #[cfg(not(target_arch = "wasm32"))] - pending_save_leases: - std::collections::HashMap, + pending_save_leases: std::collections::HashMap, /// Locked-file failure currently shown in the recovery dialog. #[cfg(not(target_arch = "wasm32"))] pending_save_failure: Option, @@ -1448,7 +1447,8 @@ impl ClipboardDeps { if !objects.is_empty() { let mut annotation_scales = Vec::new(); for (_, object) in &objects { - let acadrust::objects::ObjectType::ObjectContextData(context) = object else { + let acadrust::objects::ObjectType::ObjectContextData(context) = object + else { continue; }; if annotation_scales @@ -1618,9 +1618,7 @@ impl ClipboardDeps { fn snapshot_block(doc: &acadrust::CadDocument, name: &str) -> Option { use acadrust::EntityType; let br = doc.block_records.get(name)?; - if name.starts_with("*Model_Space") - || name.starts_with("*Paper_Space") - || br.flags.is_xref + if name.starts_with("*Model_Space") || name.starts_with("*Paper_Space") || br.flags.is_xref { return None; } @@ -1835,10 +1833,7 @@ pub enum Message { /// Register a font already held by the shared web store with the UI renderer. ApplyWebFont(crate::scene::text::web_font::Script), /// Completion of the UI renderer's runtime font registration. - WebUiFontLoaded( - crate::scene::text::web_font::Script, - Result<(), String>, - ), + WebUiFontLoaded(crate::scene::text::web_font::Script, Result<(), String>), /// Ctrl+V. Routed by `update` into an open text editor, the drawing-object /// clipboard, or the system text clipboard. PasteShortcut, @@ -1906,10 +1901,13 @@ pub enum Message { WebFileOpened(u64, crate::io::WebOpenOutcome), #[cfg(target_arch = "wasm32")] WebFileCached(u64, crate::io::WebOpenOutcome, Result<(), String>), - FileOpened(u64, Result< - (String, PathBuf, CadDocument, crate::scene::DerivedCaches), - crate::io::OpenLoadError, - >), + FileOpened( + u64, + Result< + (String, PathBuf, CadDocument, crate::scene::DerivedCaches), + crate::io::OpenLoadError, + >, + ), RecoveryClose, RecoveryAttempt, RecoveryDecline, @@ -2121,6 +2119,10 @@ pub enum Message { CommandInput(String), CommandSubmit, Command(String), + /// Execute one complete line read from a command script. Unlike UI/ribbon + /// dispatch, this accepts an interactive verb and all of its arguments on + /// the same line (`BOX 0,0,0 10,10,0 10`). + ScriptLine(String), /// Append one typed character to the command-line input from the /// global key-press subscription. Used when the text-input widget /// itself isn't focused (focus parked on viewport / button / etc.) @@ -2150,9 +2152,16 @@ pub enum Message { }, /// A widget captured Up/Down; resolve it only if the command input owns /// keyboard focus. - CommandLineArrowProbe { direction: ArrowKey, extend_selection: bool }, + CommandLineArrowProbe { + direction: ArrowKey, + extend_selection: bool, + }, /// Result of the command-input focus query for a captured Up/Down key. - CommandLineArrowResolved { direction: ArrowKey, focused: bool, extend_selection: bool }, + CommandLineArrowResolved { + direction: ArrowKey, + focused: bool, + extend_selection: bool, + }, /// Toggle the dropdown listing the full command-line history. CommandHistoryToggle, /// Grab/move/release the expanded history panel's top resize edge. @@ -2303,9 +2312,7 @@ pub enum Message { epoch: u64, source: usize, wires: std::sync::Weak>, - index: std::sync::Arc< - crate::scene::pick::interaction_index::InteractionIndex, - >, + index: std::sync::Arc, build_ms: f64, }, WindowResized(f32, f32), @@ -2567,7 +2574,10 @@ pub enum Message { PropEditChoiceToggle, /// User is typing in a block-attribute value field (live buffer update), /// keyed by the attribute tag. - PropAttrInput { tag: String, value: String }, + PropAttrInput { + tag: String, + value: String, + }, /// User committed a block-attribute value edit (Enter pressed). PropAttrCommit(String), /// Reports the currently keyboard-focused widget (if any) after a @@ -2593,7 +2603,10 @@ pub enum Message { PropColorFieldToggle(String), /// User picked a colour for a generic per-field colour row (hatch gradient /// `Color 1` / `Color 2`), routed by the field name. - PropColorFieldChanged { field: String, color: AcadColor }, + PropColorFieldChanged { + field: String, + color: AcadColor, + }, /// Collapse the inline color picker dropdown. Fired when another /// properties-panel dropdown (a combo_box) opens, so at most one panel /// dropdown is open at a time and they can't overlap. (#235) @@ -2705,7 +2718,10 @@ pub enum Message { /// Select the row the Text Options / Properties tabs act on. AttrEditorSelect(usize), /// Live edit of the attribute value at row `idx` (Attribute tab). - AttrEditorInput { idx: usize, value: String }, + AttrEditorInput { + idx: usize, + value: String, + }, // Text Options — all act on the selected row: AttrEditorTextStyle(String), AttrEditorJustify(String), @@ -2760,9 +2776,7 @@ pub enum Message { /// GitHub Discussions fetched at boot for the Start page. DiscussionsFetched(Result, String>), /// Recent-file DWG preview thumbnails decoded on a background thread. - RecentThumbsLoaded( - Vec<(std::path::PathBuf, Option)>, - ), + RecentThumbsLoaded(Vec<(std::path::PathBuf, Option)>), /// Installable releases and manifest API versions fetched for `owner/repo`. PluginReleasesFetched( String, @@ -2803,7 +2817,10 @@ pub enum Message { InvertSelection, /// Keyboard modifier state changed — tracks whether Shift is held so the /// pick path can do subtractive (Shift+click) selection. - SetModifiers { shift: bool, ctrl: bool }, + SetModifiers { + shift: bool, + ctrl: bool, + }, // ── In-place MText editor ─────────────────────────────────────────── /// Text-area edit action from the multi-line editor widget. MTextEdit(iced::widget::text_editor::Action), @@ -3273,10 +3290,7 @@ impl OpenCADStudio { .iter() .filter_map(|v| { let bytes = v.thumb.clone()?; - Some(( - v.id.clone(), - iced::widget::image::Handle::from_bytes(bytes), - )) + Some((v.id.clone(), iced::widget::image::Handle::from_bytes(bytes))) }) .collect(); self.videos = videos; @@ -3790,8 +3804,7 @@ impl OpenCADStudio { let state = Self::new(); let (id, open_task) = window::open(window::Settings { maximized: true, - icon: build_window_icon() - .and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()), + icon: build_window_icon().and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()), exit_on_close_request: false, // A Wayland compositor has no StartupWMClass to go on: it resolves a // window's dock icon by matching the window's app_id against the @@ -3844,7 +3857,7 @@ impl OpenCADStudio { Task::batch( cfg.script_lines .into_iter() - .map(|line| Task::done(Message::Command(line))), + .map(|line| Task::done(Message::ScriptLine(line))), ) }; s.queue_startup_prompts(); @@ -3927,24 +3940,17 @@ impl OpenCADStudio { let mut s = Self::new(); s.queue_startup_prompts(); let focus = s.focus_cmd_input(); - let primary_font = crate::scene::text::web_font::preload_language( - &crate::i18n::active_language_tag(), - ); + let primary_font = + crate::scene::text::web_font::preload_language(&crate::i18n::active_language_tag()); let fonts = Task::batch([ Task::done(Message::PollWebFonts), Task::done(Message::ApplyWebFont(primary_font)), ]); // Web can't reach the Patreon API directly (CORS); fetch the CI-built // supporters.json served on the same origin instead. - let patrons = Task::perform( - crate::patreon::fetch_patrons_web(), - Message::PatronsFetched, - ); + let patrons = Task::perform(crate::patreon::fetch_patrons_web(), Message::PatronsFetched); s.videos_loading = true; - let videos = Task::perform( - crate::videos::fetch_playlist_web(), - Message::VideosFetched, - ); + let videos = Task::perform(crate::videos::fetch_playlist_web(), Message::VideosFetched); s.discussions_loading = true; let discussions = Task::perform( crate::discussions::fetch_discussions_web(), @@ -3977,9 +3983,14 @@ pub fn run() -> iced::Result { if let Some(tab) = state.tabs.get(state.active_tab) { let dot = if tab.dirty { "● " } else { "" }; let name = tab.tab_display_name(); - format!("{}OCS-{} - {}", dot, env!("OCS_BUILD_STAMP"), name) + format!( + "{}Open CAD Studio {} - {}", + dot, + env!("OCS_APP_VERSION"), + name + ) } else { - concat!("OCS-", env!("OCS_BUILD_STAMP")).to_string() + concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() } }) .theme(|state: &OpenCADStudio, _| state.active_theme.clone()) @@ -4011,7 +4022,9 @@ pub fn run_web() -> iced::Result { OpenCADStudio::view_main, ) .subscription(OpenCADStudio::subscription) - .title(|_state: &OpenCADStudio| concat!("OCS-", env!("OCS_BUILD_STAMP")).to_string()) + .title(|_state: &OpenCADStudio| { + concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string() + }) .theme(|state: &OpenCADStudio| state.active_theme.clone()) .backend(iced::Backend::Hardware(iced::backend::Api::OpenGL)) .font(iced_aw::ICED_AW_FONT_BYTES) diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 38c287d23..d767d9005 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -1,29 +1,368 @@ // Kernel B-rep solid modelling and exact ACIS persistence. use acadrust::{ - entities::Solid3D, objects::SolidHistoryOperation, EntityType, Handle, + entities::{ + AcisData, EntityCommon, Region, Solid3D, Surface, SurfaceData, SurfaceKind, Wire, + }, + objects::SolidHistoryOperation, + EntityType, Handle, }; -use cadkernel::brep::Body; +use cadkernel::brep::{Body, EdgeKey, FaceKey}; use iced::Task; +use std::collections::{HashMap, HashSet}; use super::Message; use crate::modules::model::boolean_cmd::BoolOp; use crate::scene::model::solid_history; use crate::scene::model::solid_model::{self, Bool}; +#[derive(Clone, Copy, PartialEq, Eq)] +enum UnionEntityKind { + Solid, + Region, + Surface, +} + +impl UnionEntityKind { + fn from_entity(entity: &EntityType) -> Option { + Some(match entity { + EntityType::Solid3D(_) => Self::Solid, + EntityType::Region(_) => Self::Region, + EntityType::Surface(_) => Self::Surface, + _ => return None, + }) + } +} + +fn subtract_entity_kind(entity: &EntityType, convert_meshes: bool) -> Option { + UnionEntityKind::from_entity(entity).or_else(|| { + (convert_meshes + && matches!( + entity, + EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) + )) + .then_some(UnionEntityKind::Solid) + }) +} + +struct UnionGroup { + kind: UnionEntityKind, + handles: Vec, +} + +struct PreparedUnion { + kind: UnionEntityKind, + handles: Vec, + body: Body, + common: EntityCommon, +} + +struct SubtractGroup { + kind: UnionEntityKind, + plane: Option, + bases: Vec, + cutters: Vec, +} + +type PreparedSolidDisplay = ( + crate::scene::model::mesh_model::MeshLodSet, + Vec, + [f64; 3], +); + +struct PreparedSubtract { + bases: Vec, + cutters: Vec, + result: Option<(Handle, EntityType, Body, PreparedSolidDisplay)>, +} + +struct PreparedSlice { + retained: Handle, + entity: EntityType, + body: Body, + display: PreparedSolidDisplay, + extra: Option<(EntityType, Body)>, +} + +enum ModelSliceTool { + Plane(cadkernel::space::Plane), + Surface(Body), +} + +impl ModelSliceTool { + fn side(&self, point: [f64; 3]) -> Option { + match self { + Self::Plane(plane) => plane.distance_to(point), + Self::Surface(surface) => cadkernel::brep::surface_side(surface, point), + } + } + + fn split(&self, body: &Body) -> Result, cadkernel::brep::Snag> { + match self { + Self::Plane(plane) => cadkernel::brep::slice_by_plane(body, *plane), + Self::Surface(surface) => cadkernel::brep::slice_by_surface(body, surface), + } + } +} + +struct IntersectGroup { + kind: UnionEntityKind, + plane: Option, + handles: Vec, +} + +enum PreparedIntersectOutcome { + Replace { + retained: Handle, + entity: EntityType, + body: Body, + display: PreparedSolidDisplay, + }, + Consume, +} + +struct PreparedIntersect { + handles: Vec, + outcome: PreparedIntersectOutcome, +} + +fn inherited_common(source: &EntityCommon) -> EntityCommon { + let mut result = EntityCommon::new(); + result.layer = source.layer.clone(); + result.color = source.color; + result.line_weight = source.line_weight; + result.linetype = source.linetype.clone(); + result.linetype_handle = source.linetype_handle; + result.linetype_scale = source.linetype_scale; + result.transparency = source.transparency; + result.color_name = source.color_name.clone(); + result.invisible = source.invisible; + result.color_book_handle = source.color_book_handle; + result.full_visual_style_handle = source.full_visual_style_handle; + result.face_visual_style_handle = source.face_visual_style_handle; + result.edge_visual_style_handle = source.edge_visual_style_handle; + result.material_flags = source.material_flags; + result.material_handle = source.material_handle; + result.shadow_flags = source.shadow_flags; + result.plotstyle_flags = source.plotstyle_flags; + result.plotstyle_handle = source.plotstyle_handle; + result +} + +fn union_bodies( + kind: UnionEntityKind, + bodies: Vec, +) -> Result { + if kind == UnionEntityKind::Solid { + let mut operands = bodies.into_iter(); + let mut result = operands + .next() + .ok_or(cadkernel::brep::Snag::CutRefused)?; + for operand in operands { + result = solid_model::boolean_result(Bool::Union, &result, &operand)?; + } + return Ok(result); + } + let references = bodies.iter().collect::>(); + let tolerance = cadkernel::brep::operation_tolerance(&references); + cadkernel::brep::union_planar_regions(&bodies, tolerance) +} + +fn planar_body_plane(body: &Body) -> Option { + let mut faces = body.face_keys(); + let first = cadkernel::brep::planar_face_profile(body, faces.next()?)?.plane; + let first_normal = glam::DVec3::from_array(first.normal()?); + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + faces + .all(|face| { + let Some(profile) = cadkernel::brep::planar_face_profile(body, face) else { + return false; + }; + let Some(normal) = profile.plane.normal() else { + return false; + }; + first_normal.dot(glam::DVec3::from_array(normal)).abs() >= 1.0 - 1e-9 + && first + .distance_to(profile.plane.origin) + .is_some_and(|distance| distance.abs() <= tolerance * 4.0) + }) + .then_some(first) +} + +fn coplanar_bodies(plane: cadkernel::space::Plane, body: &Body) -> bool { + let Some(other) = planar_body_plane(body) else { + return false; + }; + let Some(one_normal) = plane.normal().map(glam::DVec3::from_array) else { + return false; + }; + let Some(other_normal) = other.normal().map(glam::DVec3::from_array) else { + return false; + }; + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + one_normal.dot(other_normal).abs() >= 1.0 - 1e-9 + && plane + .distance_to(other.origin) + .is_some_and(|distance| distance.abs() <= tolerance * 4.0) +} + +fn subtract_bodies( + kind: UnionEntityKind, + plane: Option, + bases: Vec, + cutters: Vec, +) -> Result { + if kind != UnionEntityKind::Solid && plane.is_none() { + return Err(cadkernel::brep::Snag::NoClosedForm); + } + if kind != UnionEntityKind::Solid && plane.is_some() { + let references = bases.iter().chain(&cutters).collect::>(); + let tolerance = cadkernel::brep::operation_tolerance(&references); + return cadkernel::brep::subtract_planar_regions(&bases, &cutters, tolerance); + } + + let mut bases = bases.into_iter(); + let mut result = bases + .next() + .ok_or(cadkernel::brep::Snag::CutRefused)?; + for base in bases { + result = solid_model::boolean_result(Bool::Union, &result, &base)?; + } + for cutter in cutters { + result = solid_model::boolean_result(Bool::Subtract, &result, &cutter)?; + } + Ok(result) +} + +fn entity_with_boolean_body(mut source: EntityType, body: &Body) -> Option { + let document = crate::scene::convert::acis_export::solid_to_sat(body)?; + let wires = solid_model::edge_wires(body); + if matches!( + &source, + EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) + ) { + let mut solid = Solid3D::new(); + solid.common = source.common().clone(); + source = EntityType::Solid3D(solid); + } + match &mut source { + EntityType::Solid3D(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + } + EntityType::Region(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + entity.set_sat_document(&document); + } + EntityType::Surface(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.history_handle = None; + if entity.kind != SurfaceKind::Plane { + entity.kind = SurfaceKind::Generic; + entity.surface_data = SurfaceData::Generic; + } + entity.acis_data = AcisData::from_sat(&document.to_sat_string()); + } + _ => return None, + } + Some(source) +} + +fn entity_with_history_body(mut source: EntityType, body: &Body) -> Option { + let document = crate::scene::convert::acis_export::solid_to_sat(body)?; + let wires = solid_model::edge_wires(body); + match &mut source { + EntityType::Solid3D(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + entity.set_sat_document(&document); + } + EntityType::Surface(entity) => { + entity.wires = wires; + entity.silhouettes.clear(); + if entity.kind != SurfaceKind::Plane { + entity.kind = SurfaceKind::Generic; + entity.surface_data = SurfaceData::Generic; + } + entity.acis_data = AcisData::from_sat(&document.to_sat_string()); + } + _ => return None, + } + Some(source) +} + +enum IntersectBodyOutcome { + Area(Body), + Touching, + Disjoint, +} + +fn intersect_bodies( + kind: UnionEntityKind, + bodies: Vec, +) -> Result { + if kind == UnionEntityKind::Solid { + let mut operands = bodies.into_iter(); + let mut result = operands + .next() + .ok_or(cadkernel::brep::Snag::CutRefused)?; + for operand in operands { + result = solid_model::boolean_result(Bool::Intersect, &result, &operand)?; + if result.faces.is_empty() { + return Ok(IntersectBodyOutcome::Disjoint); + } + } + return Ok(IntersectBodyOutcome::Area(result)); + } + + let references = bodies.iter().collect::>(); + let tolerance = cadkernel::brep::operation_tolerance(&references); + Ok(match cadkernel::brep::intersect_planar_regions(&bodies, tolerance)? { + cadkernel::brep::PlanarIntersection::Area(body) => IntersectBodyOutcome::Area(body), + cadkernel::brep::PlanarIntersection::Touching => IntersectBodyOutcome::Touching, + cadkernel::brep::PlanarIntersection::Disjoint => IntersectBodyOutcome::Disjoint, + }) +} + impl super::OpenCADStudio { /// Add a solid and register its persistent B-rep. pub(super) fn add_solid_model( + &mut self, + entity: EntityType, + solid: Body, + history: SolidHistoryOperation, + ) -> Handle { + self.add_solid_model_inner(entity, solid, history, true) + } + + fn add_solid_model_preserving_style( + &mut self, + entity: EntityType, + solid: Body, + history: SolidHistoryOperation, + ) -> Handle { + self.add_solid_model_inner(entity, solid, history, false) + } + + fn add_solid_model_inner( &mut self, mut entity: EntityType, solid: Body, history: SolidHistoryOperation, + apply_creation_style: bool, ) -> Handle { let i = self.active_tab; let EntityType::Solid3D(inner) = &mut entity else { return Handle::NULL; }; - inner.common.plotstyle_flags = 2; + if apply_creation_style { + inner.common.plotstyle_flags = 2; + } inner.wires = solid_model::edge_wires(&solid); let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&solid) else { @@ -32,7 +371,12 @@ impl super::OpenCADStudio { return Handle::NULL; }; inner.set_sat_document(&document); - let Some(handle) = self.commit_entity_handle(entity) else { + let handle = if apply_creation_style { + self.commit_entity_handle(entity) + } else { + self.commit_entity_handle_preserve_style(entity) + }; + let Some(handle) = handle else { return Handle::NULL; }; let require_complete = matches!(history, SolidHistoryOperation::Loft(_)); @@ -51,15 +395,34 @@ impl super::OpenCADStudio { /// Add an open sheet body as a persistent Surface entity and register its /// exact B-rep for shaded and wireframe display. pub(super) fn add_surface_model( + &mut self, + entity: EntityType, + surface: Body, + ) -> Handle { + self.add_surface_model_inner(entity, surface, true) + } + + fn add_surface_model_preserving_style( + &mut self, + entity: EntityType, + surface: Body, + ) -> Handle { + self.add_surface_model_inner(entity, surface, false) + } + + fn add_surface_model_inner( &mut self, mut entity: EntityType, surface: Body, + apply_creation_style: bool, ) -> Handle { let i = self.active_tab; let EntityType::Surface(inner) = &mut entity else { return Handle::NULL; }; - inner.common.plotstyle_flags = 2; + if apply_creation_style { + inner.common.plotstyle_flags = 2; + } inner.wires = solid_model::edge_wires(&surface); let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&surface) else { @@ -68,7 +431,12 @@ impl super::OpenCADStudio { return Handle::NULL; }; inner.acis_data = acadrust::entities::AcisData::from_sat(&document.to_sat_string()); - let Some(handle) = self.commit_entity_handle(entity) else { + let handle = if apply_creation_style { + self.commit_entity_handle(entity) + } else { + self.commit_entity_handle_preserve_style(entity) + }; + let Some(handle) = handle else { return Handle::NULL; }; let require_complete = self.tabs[i].scene.document.get_entity(handle).is_some_and(|entity| @@ -81,6 +449,44 @@ impl super::OpenCADStudio { handle } + pub(super) fn add_region_model(&mut self, region: Region, body: Body) -> Handle { + self.add_region_model_inner(region, body, false) + } + + fn add_region_model_preserving_style(&mut self, region: Region, body: Body) -> Handle { + self.add_region_model_inner(region, body, true) + } + + fn add_region_model_inner( + &mut self, + mut region: Region, + body: Body, + preserve_style: bool, + ) -> Handle { + let i = self.active_tab; + region.wires = solid_model::edge_wires(&body); + let Some(document) = crate::scene::convert::acis_export::solid_to_sat(&body) else { + self.command_line + .push_error(crate::t!("The region could not be encoded as ACIS.").as_ref()); + return Handle::NULL; + }; + region.set_sat_document(&document); + let entity = EntityType::Region(region); + let handle = if preserve_style { + self.commit_entity_handle_preserve_style(entity) + } else { + self.commit_entity_handle(entity) + }; + let Some(handle) = handle else { + return Handle::NULL; + }; + if !self.tabs[i].scene.register_solid_model(handle, body) { + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } + handle + } + fn selected_solid_handles(&mut self) -> Vec { let i = self.active_tab; let mut handles: Vec = self.tabs[i] @@ -100,44 +506,266 @@ impl super::OpenCADStudio { handles } - fn replace_solid_body(&mut self, handle: Handle, result: Body, label: &str) -> bool { - let i = self.active_tab; - let Some(document) = - crate::scene::convert::acis_export::solid_to_sat(&result) - else { - self.command_line - .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); - return false; - }; - let Some(EntityType::Solid3D(mut entity)) = - self.tabs[i].scene.document.get_entity(handle).cloned() - else { - return false; - }; - entity.wires = solid_model::edge_wires(&result); - entity.silhouettes.clear(); - entity.history_handle = None; - entity.set_sat_document(&document); + fn selected_union_groups(&self) -> Vec { + let scene = &self.tabs[self.active_tab].scene; + let mut groups: Vec = Vec::new(); + for handle in scene.selected_handles_in_order() { + if scene.is_layer_locked(handle) { + continue; + } + let Some(kind) = scene + .document + .get_entity(handle) + .and_then(UnionEntityKind::from_entity) + else { + continue; + }; + if let Some(group) = groups.iter_mut().find(|group| group.kind == kind) { + group.handles.push(handle); + } else { + groups.push(UnionGroup { + kind, + handles: vec![handle], + }); + } + } + groups + } - let Some(display) = self.tabs[i].scene.prepare_solid_model_display(handle, &result) + pub(super) fn union_ready(&self) -> bool { + self.selected_union_groups() + .iter() + .any(|group| group.handles.len() >= 2) + } + + pub(super) fn intersect_ready(&self) -> bool { + self.union_ready() + } + + fn selected_intersect_handles(&self) -> Vec { + let scene = &self.tabs[self.active_tab].scene; + scene + .selected_handles_in_order() + .into_iter() + .filter(|handle| !scene.is_layer_locked(*handle)) + .filter(|handle| { + scene + .document + .get_entity(*handle) + .and_then(UnionEntityKind::from_entity) + .is_some() + }) + .collect() + } + + fn intersect_groups( + &self, + handles: &[Handle], + bodies: &HashMap, + ) -> Vec { + let scene = &self.tabs[self.active_tab].scene; + let mut groups = Vec::::new(); + for handle in handles { + let Some(kind) = scene + .document + .get_entity(*handle) + .and_then(UnionEntityKind::from_entity) + else { + continue; + }; + let plane = (kind != UnionEntityKind::Solid) + .then(|| planar_body_plane(&bodies[handle])) + .flatten(); + if let Some(group) = groups.iter_mut().find(|group| { + group.kind == kind + && if kind == UnionEntityKind::Solid { + true + } else { + match (group.plane, plane) { + (Some(group_plane), Some(_)) => { + coplanar_bodies(group_plane, &bodies[handle]) + } + (None, None) => true, + _ => false, + } + } + }) { + group.handles.push(*handle); + } else { + groups.push(IntersectGroup { + kind, + plane, + handles: vec![*handle], + }); + } + } + groups.retain(|group| group.handles.len() >= 2); + groups + } + + fn replace_solid_body_with_fillet( + &mut self, + handle: Handle, + source: &Body, + result: Body, + edges: &[EdgeKey], + radius: f64, + ) -> bool { + let i = self.active_tab; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(handle, &result) .filter(|display| display.0.complete) else { - self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref()); + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original edge body was retained.").as_ref()); return false; }; - self.push_undo_snapshot(i, label); - self.tabs[i].scene.delete_solid_history(handle); - if !self.tabs[i] - .scene - .update_entity(EntityType::Solid3D(entity)) + let ordered = source.edge_keys().collect::>(); + let Some(history_edges) = edges + .iter() + .map(|edge| { + ordered + .iter() + .position(|candidate| candidate == edge) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + }) + .collect::>>() + else { + self.command_line + .push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref()); + return false; + }; + + self.push_undo_snapshot(i, "FILLETEDGE"); + if self.tabs[i].scene.document.solid_history_graph(handle).is_none() + && !self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(source)) { self.command_line - .push_error(crate::t!("The solid could not be updated.").as_ref()); + .push_error(crate::t!("The edge body history could not be created.").as_ref()); return false; } - let history = solid_history::brep_op(&result); - self.tabs[i].scene.create_solid_history(handle, history); - self.tabs[i].scene.register_prepared_solid_model(handle, result, display); + if !self.tabs[i].scene.append_solid_history( + handle, + solid_history::fillet_op(history_edges, radius), + ) { + self.command_line + .push_error(crate::t!("The fillet history could not be recorded.").as_ref()); + return false; + } + let _ = self.tabs[i].scene.apply_solid_history_choice( + handle, + solid_history::PROP_HISTORY, + "Record", + ); + let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { + return false; + }; + let Some(entity) = entity_with_history_body(entity, &result) else { + return false; + }; + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The edge body could not be updated.").as_ref()); + return false; + } + self.tabs[i] + .scene + .register_prepared_solid_model(handle, result, display); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + true + } + + fn replace_edge_body_with_chamfer( + &mut self, + handle: Handle, + source: &Body, + result: Body, + edges: &[EdgeKey], + base_face: FaceKey, + base_distance: f64, + other_distance: f64, + ) -> bool { + let i = self.active_tab; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(handle, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original edge body was retained.").as_ref()); + return false; + }; + let ordered_edges = source.edge_keys().collect::>(); + let Some(history_edges) = edges + .iter() + .map(|edge| { + ordered_edges + .iter() + .position(|candidate| candidate == edge) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + }) + .collect::>>() + else { + self.command_line + .push_error(crate::t!("A selected edge no longer belongs to the body.").as_ref()); + return false; + }; + let Some(history_face) = source + .face_keys() + .position(|candidate| candidate == base_face) + .and_then(|ordinal| i32::try_from(ordinal).ok()) + else { + self.command_line + .push_error(crate::t!("The selected base face no longer belongs to the body.").as_ref()); + return false; + }; + + self.push_undo_snapshot(i, "CHAMFEREDGE"); + if self.tabs[i].scene.document.solid_history_graph(handle).is_none() + && !self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(source)) + { + self.command_line + .push_error(crate::t!("The edge body history could not be created.").as_ref()); + return false; + } + if !self.tabs[i].scene.append_solid_history( + handle, + solid_history::chamfer_op( + history_edges, + history_face, + base_distance, + other_distance, + ), + ) { + self.command_line + .push_error(crate::t!("The chamfer history could not be recorded.").as_ref()); + return false; + } + let _ = self.tabs[i].scene.apply_solid_history_choice( + handle, + solid_history::PROP_HISTORY, + "Record", + ); + let Some(entity) = self.tabs[i].scene.document.get_entity(handle).cloned() else { + return false; + }; + let Some(entity) = entity_with_history_body(entity, &result) else { + return false; + }; + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The edge body could not be updated.").as_ref()); + return false; + } + self.tabs[i] + .scene + .register_prepared_solid_model(handle, result, display); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entity(handle, false); self.tabs[i].dirty = true; @@ -148,10 +776,99 @@ impl super::OpenCADStudio { pub(super) fn solid_edge_blend( &mut self, handle: Handle, - pick: glam::DVec3, + edges: &[EdgeKey], + base_face: Option, value: f64, + other_value: f64, fillet: bool, ) -> Task { + let i = self.active_tab; + if self.reject_locked_edit(i, handle) { + return Task::none(); + } + if !matches!(self.tabs[i].scene.document.get_entity(handle), Some( + EntityType::Solid3D(_) | EntityType::Surface(_) + )) { + self.command_line + .push_error(crate::t!("Select a 3D solid or surface edge.").as_ref()); + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); + let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { + self.command_line + .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); + return Task::none(); + }; + let result = if fillet { + match cadkernel::brep::fillet_edges(&body, edges, value) { + Ok(result) => Some(result), + Err(error) => { + self.command_line + .push_error(&format!("FILLETEDGE: {error}")); + return Task::none(); + } + } + } else { + let Some(base_face) = base_face else { + self.command_line + .push_error(crate::t!("CHAMFEREDGE: select a base face edge.").as_ref()); + return Task::none(); + }; + match cadkernel::brep::chamfer_edges( + &body, + edges, + base_face, + value, + other_value, + ) { + Ok(result) => Some(result), + Err(error) => { + self.command_line + .push_error(&format!("CHAMFEREDGE: {error}")); + return Task::none(); + } + } + }; + let Some(result) = result else { + self.command_line.push_error( + crate::t!("Edge operation failed. Use a convex planar solid and a smaller value.") + .as_ref(), + ); + return Task::none(); + }; + let label = if fillet { "FILLETEDGE" } else { "CHAMFEREDGE" }; + let updated = if fillet { + self.replace_solid_body_with_fillet(handle, &body, result, edges, value) + } else { + self.replace_edge_body_with_chamfer( + handle, + &body, + result, + edges, + base_face.expect("checked chamfer face"), + value, + other_value, + ) + }; + if updated { + if !fillet { + self.tabs[i].scene.document.header.chamfer_distance_a = value; + self.tabs[i].scene.document.header.chamfer_distance_b = other_value; + } + self.command_line + .push_output(crate::tf!("{label}: solid updated.").as_ref()); + } + Task::none() + } + + pub(super) fn solid_shell( + &mut self, + handle: Handle, + actions: &[crate::command::ShellFaceAction], + distance: f64, + ) -> Task { + use crate::command::ShellFaceAction; + let i = self.active_tab; if self.reject_locked_edit(i, handle) { return Task::none(); @@ -161,42 +878,319 @@ impl super::OpenCADStudio { Some(EntityType::Solid3D(_)) ) { self.command_line - .push_error(crate::t!("Select a 3D solid edge.").as_ref()); + .push_error(crate::t!("Select a 3D solid.").as_ref()); return Task::none(); } + if !distance.is_finite() || distance.abs() <= f64::EPSILON { + self.command_line + .push_error(crate::t!("The shell offset distance must be nonzero.").as_ref()); + return Task::none(); + } + self.tabs[i].scene.restore_solid_models(&[handle]); let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else { self.command_line .push_error(crate::t!("The solid geometry could not be restored.").as_ref()); return Task::none(); }; - let Some(edge) = solid_model::nearest_edge(&body, pick.to_array()) else { - self.command_line - .push_error(crate::t!("Select a solid edge.").as_ref()); + + let all_faces = body.face_keys().collect::>(); + let mut removed = HashSet::new(); + for action in actions { + match *action { + ShellFaceAction::Remove(point) => { + let Some(face) = cadkernel::brep::shell_face_at_point(&body, point.to_array()) + else { + self.command_line.push_error( + crate::t!("A selected face could not be resolved.").as_ref(), + ); + return Task::none(); + }; + removed.insert(face); + } + ShellFaceAction::Add(point) => { + let Some(face) = cadkernel::brep::shell_face_at_point(&body, point.to_array()) + else { + self.command_line.push_error( + crate::t!("A selected face could not be resolved.").as_ref(), + ); + return Task::none(); + }; + removed.remove(&face); + } + ShellFaceAction::RemoveAll => removed.extend(all_faces.iter().copied()), + ShellFaceAction::AddAll => removed.clear(), + } + } + let removed = removed.into_iter().collect::>(); + + let result = match cadkernel::brep::shell(&body, &removed, distance) { + Ok(result) => result, + Err(cadkernel::brep::ShellError::InvalidDistance) => { + self.command_line + .push_error(crate::t!("The shell offset distance must be nonzero.").as_ref()); + return Task::none(); + } + Err(cadkernel::brep::ShellError::UnsupportedSolid) => { + self.command_line.push_error( + crate::t!("SHELL supports rectangular boxes, circular cylinders, and spheres.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::ShellError::UnknownFace) => { + self.command_line + .push_error(crate::t!("A selected face does not belong to the solid.").as_ref()); + return Task::none(); + } + Err(cadkernel::brep::ShellError::NoMaterial) => { + self.command_line.push_error( + crate::t!("The offset removes all material. Use a smaller distance or add a face back.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::ShellError::Kernel(_)) => { + self.command_line.push_error( + crate::t!("The shell could not be constructed from the selected solid and distance.") + .as_ref(), + ); + return Task::none(); + } + }; + + let Some(source) = self.tabs[i].scene.document.get_entity(handle).cloned() else { return Task::none(); }; - let result = if fillet { - cadkernel::brep::fillet(&body, edge, value) - } else { - cadkernel::brep::chamfer(&body, edge, value) + let Some(entity) = entity_with_boolean_body(source, &result) else { + self.command_line + .push_error(crate::t!("The result could not be encoded as ACIS.").as_ref()); + return Task::none(); }; - let Some(result) = result else { + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(handle, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref()); + return Task::none(); + }; + + self.push_undo_snapshot(i, "SHELL"); + self.tabs[i].scene.delete_solid_history(handle); + if !self.tabs[i].scene.update_entity(entity) { + self.command_line + .push_error(crate::t!("The solid could not be updated.").as_ref()); + return Task::none(); + } + self.tabs[i] + .scene + .create_solid_history(handle, solid_history::brep_op(&result)); + self.tabs[i] + .scene + .register_prepared_solid_model(handle, result, display); + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entity(handle, false); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line + .push_output(crate::t!("SHELL: solid updated.").as_ref()); + Task::none() + } + + /// Intersect compatible selected solids, Regions, and coplanar Surfaces. + pub(super) fn solid_intersect(&mut self) -> Task { + let i = self.active_tab; + let mut handles = self.selected_intersect_handles(); + self.tabs[i].scene.restore_solid_models(&handles); + handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + let bodies = handles + .iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .solid_models + .get(handle) + .cloned() + .map(|body| (*handle, body)) + }) + .collect::>(); + let groups = self.intersect_groups(&handles, &bodies); + if groups.is_empty() { self.command_line.push_error( - crate::t!("Edge operation failed. Use a convex planar solid and a smaller value.") + crate::t!("INTERSECT: select at least two solids, Regions, or coplanar Surfaces of a compatible type.") .as_ref(), ); return Task::none(); - }; - let label = if fillet { "SOLIDFILLET" } else { "SOLIDCHAMFER" }; - if self.replace_solid_body(handle, result, label) { - self.command_line - .push_output(crate::tf!("{label}: solid updated.").as_ref()); } + + let mut prepared = Vec::new(); + let mut retained_without_change = 0usize; + for group in groups { + let operands = group + .handles + .iter() + .map(|handle| bodies[handle].clone()) + .collect::>(); + let outcome = match intersect_bodies(group.kind, operands) { + Ok(outcome) => outcome, + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected objects do not have a supported coplanar surface intersection.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected coincident geometry is ambiguous.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("INTERSECT: the selected topology could not be intersected safely.") + .as_ref(), + ); + return Task::none(); + } + }; + + match outcome { + IntersectBodyOutcome::Area(body) => { + let retained = group.handles[0]; + let Some(source) = self.tabs[i].scene.document.get_entity(retained).cloned() + else { + return Task::none(); + }; + let Some(entity) = entity_with_boolean_body(source, &body) else { + self.command_line.push_error( + crate::t!("The INTERSECT result could not be encoded as ACIS.") + .as_ref(), + ); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(retained, &body) + .filter(|display| display.0.complete) + else { + self.command_line.push_error( + crate::t!("The INTERSECT result could not be displayed completely. The original objects were retained.") + .as_ref(), + ); + return Task::none(); + }; + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Replace { + retained, + entity, + body, + display, + }, + }); + } + IntersectBodyOutcome::Touching + if group.kind == UnionEntityKind::Region + || group.kind == UnionEntityKind::Surface => + { + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Consume, + }); + } + IntersectBodyOutcome::Disjoint if group.kind == UnionEntityKind::Region => { + prepared.push(PreparedIntersect { + handles: group.handles, + outcome: PreparedIntersectOutcome::Consume, + }); + } + IntersectBodyOutcome::Touching | IntersectBodyOutcome::Disjoint => { + retained_without_change += group.handles.len(); + } + } + } + + if prepared.is_empty() { + self.command_line.push_output( + crate::tf!( + "INTERSECT: no positive-area common result; %{count} original object(s) retained.", + count = retained_without_change + ) + .as_ref(), + ); + return Task::none(); + } + + let record_history = self.tabs[i].scene.document.header.record_solid_history; + self.push_undo_snapshot(i, "INTERSECT"); + let mut results = Vec::new(); + let mut consumed = Vec::new(); + for group in prepared { + match group.outcome { + PreparedIntersectOutcome::Replace { + retained, + entity, + body, + display, + } => { + self.tabs[i].scene.delete_solid_history(retained); + if !self.tabs[i].scene.update_entity(entity) { + self.command_line.push_error( + crate::t!("INTERSECT: the retained object could not be updated.") + .as_ref(), + ); + return Task::none(); + } + if record_history + && matches!( + self.tabs[i].scene.document.get_entity(retained), + Some(EntityType::Solid3D(_)) + ) + { + let history = solid_history::brep_op(&body); + self.tabs[i].scene.create_solid_history(retained, history); + } + self.tabs[i] + .scene + .register_prepared_solid_model(retained, body, display); + results.push(retained); + consumed.extend( + group + .handles + .into_iter() + .filter(|handle| *handle != retained), + ); + } + PreparedIntersectOutcome::Consume => consumed.extend(group.handles), + } + } + self.tabs[i].scene.erase_entities(&consumed); + self.tabs[i].scene.deselect_all(); + for handle in &results { + self.tabs[i].scene.select_entity(*handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!( + "INTERSECT: created %{count} result object(s).", + count = results.len() + ) + .as_ref(), + ); Task::none() } /// Run a boolean over the selected solids in selection order. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { + match op { + BoolOp::Union => return self.union_selected_entities(), + BoolOp::Intersect => return self.solid_intersect(), + BoolOp::Subtract => {} + } let i = self.active_tab; let handles = self.selected_solid_handles(); if handles.len() < 2 { @@ -243,151 +1237,636 @@ impl super::OpenCADStudio { Task::none() } - pub(super) fn solid_subtract( - &mut self, - bases: &[Handle], - cutters: &[Handle], - ) -> Task { + fn union_selected_entities(&mut self) -> Task { let i = self.active_tab; - let valid_solid = |handle: &Handle| { - !self.tabs[i].scene.is_layer_locked(*handle) - && matches!( - self.tabs[i].scene.document.get_entity(*handle), - Some(EntityType::Solid3D(_)) - ) - }; - let mut base_handles: Vec<_> = bases.iter().copied().filter(valid_solid).collect(); - let mut cutter_handles: Vec<_> = cutters.iter().copied().filter(valid_solid).collect(); - cutter_handles.retain(|handle| !base_handles.contains(handle)); - - let mut operands = base_handles.clone(); - operands.extend(cutter_handles.iter().copied()); - self.tabs[i].scene.restore_solid_models(&operands); - base_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); - cutter_handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); - if base_handles.is_empty() || cutter_handles.is_empty() { + let mut groups = self.selected_union_groups(); + let handles = groups + .iter() + .flat_map(|group| group.handles.iter().copied()) + .collect::>(); + self.tabs[i].scene.restore_solid_models(&handles); + for group in &mut groups { + group + .handles + .retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + } + groups.retain(|group| group.handles.len() >= 2); + if groups.is_empty() { self.command_line.push_error( - crate::t!("SUBTRACT: select at least one base solid and one cutter solid.") + crate::t!("UNION: select at least two solids, Regions, or planar Surfaces of the same type.") .as_ref(), ); return Task::none(); } - let mut result = self.tabs[i].scene.solid_models[&base_handles[0]].clone(); - for handle in &base_handles[1..] { - let operand = &self.tabs[i].scene.solid_models[handle]; - let Some(combined) = solid_model::boolean(Bool::Union, &result, operand) else { + let mut prepared = Vec::with_capacity(groups.len()); + for group in groups { + let bodies = group + .handles + .iter() + .filter_map(|handle| self.tabs[i].scene.solid_models.get(handle).cloned()) + .collect::>(); + let result = match union_bodies(group.kind, bodies) { + Ok(result) => result, + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("UNION: the selected geometry includes an unsupported surface intersection.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("UNION: the selected coincident geometry is ambiguous.").as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("UNION: the selected topology could not be closed safely.").as_ref(), + ); + return Task::none(); + } + }; + if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { self.command_line.push_error( - crate::t!("SUBTRACT failed while combining the base solids.").as_ref(), + crate::t!("The UNION result could not be encoded as ACIS.").as_ref(), ); return Task::none(); - }; - result = combined; - } - for handle in &cutter_handles { - let operand = &self.tabs[i].scene.solid_models[handle]; - let Some(difference) = solid_model::boolean(Bool::Subtract, &result, operand) else { - self.command_line.push_error( - crate::t!("SUBTRACT failed while removing the selected solids.").as_ref(), - ); + } + let Some(source) = group + .handles + .first() + .and_then(|handle| self.tabs[i].scene.document.get_entity(*handle)) + else { return Task::none(); }; - result = difference; - } - if crate::scene::convert::acis_export::solid_to_sat(&result).is_none() { - self.command_line - .push_error(crate::t!("The boolean result could not be encoded as ACIS.").as_ref()); - return Task::none(); + prepared.push(PreparedUnion { + kind: group.kind, + handles: group.handles, + body: result, + common: inherited_common(source.common()), + }); } - operands = base_handles; - operands.extend(cutter_handles); - self.push_undo_snapshot(i, "SUBTRACT"); - self.tabs[i].scene.erase_entities(&operands); - let mut entity = Solid3D::new(); - entity.wires = solid_model::edge_wires(&result); - let history = solid_history::brep_op(&result); - let handle = self.add_solid_model(EntityType::Solid3D(entity), result, history); + let consumed = prepared + .iter() + .flat_map(|group| group.handles.iter().copied()) + .collect::>(); + self.push_undo_snapshot(i, "UNION"); + let mut created = Vec::with_capacity(prepared.len()); + for group in prepared { + let handle = match group.kind { + UnionEntityKind::Solid => { + let history = solid_history::brep_op(&group.body); + let mut entity = Solid3D::new(); + entity.common = group.common; + self.add_solid_model_preserving_style( + EntityType::Solid3D(entity), + group.body, + history, + ) + } + UnionEntityKind::Region => { + let mut entity = Region::new(); + entity.common = group.common; + self.add_region_model_preserving_style(entity, group.body) + } + UnionEntityKind::Surface => { + let mut entity = Surface::new(SurfaceKind::Generic); + entity.common = group.common; + self.add_surface_model_preserving_style( + EntityType::Surface(entity), + group.body, + ) + } + }; + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + return Task::none(); + } + created.push(handle); + } + + self.tabs[i].scene.erase_entities(&consumed); self.tabs[i].scene.deselect_all(); - if !handle.is_null() { - self.tabs[i].scene.select_entity(handle, false); + for handle in &created { + self.tabs[i].scene.select_entity(*handle, false); } self.tabs[i].dirty = true; self.refresh_properties(); + self.command_line.push_output( + crate::tf!("UNION: created %{count} result object(s).", count = created.len()).as_ref(), + ); Task::none() } - /// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 = - /// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The - /// kept half is the intersection of the solid with a half-space box, reusing - /// the same boolean path as the modelling tools. - pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task { + pub(super) fn solid_subtract( + &mut self, + bases: &[Handle], + cutters: &[Handle], + convert_meshes: bool, + ) -> Task { let i = self.active_tab; - let handles = self.selected_solid_handles(); - if handles.len() != 1 { - self.command_line - .push_error(crate::t!("SLICE: select exactly one solid created this session.").as_ref()); - return Task::none(); - } - let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); - // Bounding box from the solid's edge wires. - let Some((min, max)) = solid_model::extent(&solid) else { - self.command_line - .push_error(crate::t!("SLICE: could not determine the solid's extent.").as_ref()); - return Task::none(); + let valid_operand = |handle: &Handle| { + !self.tabs[i].scene.is_layer_locked(*handle) + && self.tabs[i] + .scene + .document + .get_entity(*handle) + .and_then(|entity| subtract_entity_kind(entity, convert_meshes)) + .is_some() }; - // Generous margin so the box fully spans the solid in the free axes. - let m = [ - (max[0] - min[0]).max(1.0), - (max[1] - min[1]).max(1.0), - (max[2] - min[2]).max(1.0), - ]; - let mut lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]]; - let mut hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]]; - if keep_low { - hi[axis] = value; - } else { - lo[axis] = value; + let mut base_handles: Vec<_> = bases.iter().copied().filter(valid_operand).collect(); + let mut cutter_handles: Vec<_> = cutters.iter().copied().filter(valid_operand).collect(); + cutter_handles.retain(|handle| !base_handles.contains(handle)); + + let mut operands = base_handles.clone(); + operands.extend(cutter_handles.iter().copied()); + self.tabs[i].scene.restore_solid_models(&operands); + let mut operand_bodies = std::collections::HashMap::::new(); + for handle in &operands { + if let Some(body) = self.tabs[i].scene.solid_models.get(handle).cloned() { + operand_bodies.insert(*handle, body); + continue; + } + let Some(entity) = self.tabs[i].scene.document.get_entity(*handle) else { + continue; + }; + if convert_meshes + && matches!( + entity, + EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) + ) + { + let Some(body) = crate::entities::mesh::closed_mesh_body(entity) else { + self.command_line.push_error( + crate::t!("SUBTRACT: a selected Mesh is not a supported closed single-shell mesh; no object was changed.") + .as_ref(), + ); + return Task::none(); + }; + operand_bodies.insert(*handle, body); + } } - if hi[axis] <= lo[axis] { - self.command_line - .push_error(crate::t!("SLICE: the plane does not cross the solid on the kept side.").as_ref()); + base_handles.retain(|handle| operand_bodies.contains_key(handle)); + cutter_handles.retain(|handle| operand_bodies.contains_key(handle)); + if base_handles.is_empty() || cutter_handles.is_empty() { + self.command_line.push_error( + crate::t!("SUBTRACT: select at least one base and one cutter Solid, Region, or Surface.") + .as_ref(), + ); return Task::none(); } - let center = [ - (lo[0] + hi[0]) / 2.0, - (lo[1] + hi[1]) / 2.0, - (lo[2] + hi[2]) / 2.0, - ]; - let halfspace = solid_model::box_solid(center, hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]); - let Some(result) = halfspace - .as_ref() - .and_then(|half| solid_model::boolean(Bool::Intersect, &solid, half)) - else { - self.command_line - .push_error(crate::t!("SLICE failed — the plane may not cross the solid.").as_ref()); - return Task::none(); + + let kind_for = |handle: Handle| { + self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| subtract_entity_kind(entity, convert_meshes)) }; - self.push_undo_snapshot(i, "SLICE"); - self.tabs[i].scene.erase_entities(&handles); - let mut s3d = Solid3D::new(); - s3d.wires = solid_model::edge_wires(&result); - let history = solid_history::brep_op(&result); - let handle = self.add_solid_model(EntityType::Solid3D(s3d), result, history); + let mut groups = Vec::::new(); + for kind in [ + UnionEntityKind::Solid, + UnionEntityKind::Region, + UnionEntityKind::Surface, + ] { + let kind_bases = base_handles + .iter() + .copied() + .filter(|handle| kind_for(*handle) == Some(kind)) + .collect::>(); + if kind_bases.is_empty() { + continue; + } + if kind == UnionEntityKind::Solid { + let kind_cutters = cutter_handles + .iter() + .copied() + .filter(|handle| kind_for(*handle) == Some(kind)) + .collect::>(); + if !kind_cutters.is_empty() { + groups.push(SubtractGroup { + kind, + plane: None, + bases: kind_bases, + cutters: kind_cutters, + }); + } + continue; + } + + for handle in kind_bases { + let body = &operand_bodies[&handle]; + let plane = planar_body_plane(body); + if let Some(group) = groups.iter_mut().find(|group| { + group.kind == kind + && match (group.plane, plane) { + (Some(group_plane), Some(_)) => coplanar_bodies(group_plane, body), + (None, None) => true, + _ => false, + } + }) { + group.bases.push(handle); + } else { + groups.push(SubtractGroup { + kind, + plane, + bases: vec![handle], + cutters: Vec::new(), + }); + } + } + for handle in cutter_handles + .iter() + .copied() + .filter(|handle| kind_for(*handle) == Some(kind)) + { + let body = &operand_bodies[&handle]; + let plane = planar_body_plane(body); + if let Some(group) = groups.iter_mut().find(|group| { + group.kind == kind + && match (group.plane, plane) { + (Some(group_plane), Some(_)) => coplanar_bodies(group_plane, body), + (None, None) => true, + _ => false, + } + }) { + group.cutters.push(handle); + } + } + } + groups.retain(|group| !group.bases.is_empty() && !group.cutters.is_empty()); + if groups.is_empty() { + self.command_line.push_error( + crate::t!("SUBTRACT: no compatible base and cutter types or planes were selected.") + .as_ref(), + ); + return Task::none(); + } + + let mut prepared = Vec::with_capacity(groups.len()); + for group in groups { + let base_bodies = group + .bases + .iter() + .map(|handle| operand_bodies[handle].clone()) + .collect::>(); + let cutter_bodies = group + .cutters + .iter() + .map(|handle| operand_bodies[handle].clone()) + .collect::>(); + let result = match subtract_bodies(group.kind, group.plane, base_bodies, cutter_bodies) { + Ok(result) => result, + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("SUBTRACT: the selected geometry includes an unsupported surface intersection.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("SUBTRACT: the selected coincident geometry is ambiguous.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("SUBTRACT: the selected topology could not be cut safely.") + .as_ref(), + ); + return Task::none(); + } + }; + let staged_result = if result.faces.is_empty() { + None + } else { + let retained = group.bases[0]; + let Some(source) = self.tabs[i].scene.document.get_entity(retained).cloned() else { + return Task::none(); + }; + let Some(entity) = entity_with_boolean_body(source, &result) else { + self.command_line.push_error( + crate::t!("The SUBTRACT result could not be encoded as ACIS.").as_ref(), + ); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(retained, &result) + .filter(|display| display.0.complete) + else { + self.command_line.push_error( + crate::t!("The SUBTRACT result could not be displayed completely. The original objects were retained.") + .as_ref(), + ); + return Task::none(); + }; + Some((retained, entity, result, display)) + }; + prepared.push(PreparedSubtract { + bases: group.bases, + cutters: group.cutters, + result: staged_result, + }); + } + + let record_history = self.tabs[i].scene.document.header.record_solid_history; + self.push_undo_snapshot(i, "SUBTRACT"); + let mut retained = Vec::new(); + let mut consumed = Vec::new(); + for group in prepared { + if let Some((handle, entity, body, display)) = group.result { + self.tabs[i].scene.delete_solid_history(handle); + if !self.tabs[i].scene.update_entity(entity) { + self.command_line.push_error( + crate::t!("SUBTRACT: the retained base object could not be updated.").as_ref(), + ); + return Task::none(); + } + if record_history + && matches!( + self.tabs[i].scene.document.get_entity(handle), + Some(EntityType::Solid3D(_)) + ) + { + let history = solid_history::brep_op(&body); + self.tabs[i].scene.create_solid_history(handle, history); + } + self.tabs[i] + .scene + .register_prepared_solid_model(handle, body, display); + retained.push(handle); + consumed.extend(group.bases.into_iter().skip(1)); + } else { + consumed.extend(group.bases); + } + consumed.extend(group.cutters); + } + self.tabs[i].scene.erase_entities(&consumed); self.tabs[i].scene.deselect_all(); - if !handle.is_null() { - self.tabs[i].scene.select_entity(handle, false); + for handle in &retained { + self.tabs[i].scene.select_entity(*handle, false); } self.tabs[i].dirty = true; self.refresh_properties(); - let ax = ["X", "Y", "Z"][axis]; - self.command_line.push_output(crate::tf!( - "SLICE: cut at {ax}={value}, kept the {} half.", - if keep_low { "lower" } else { "upper" } - ).as_ref()); + self.command_line.push_output( + crate::tf!( + "SUBTRACT: created %{count} result object(s).", + count = retained.len() + ) + .as_ref(), + ); Task::none() } + /// Split every selected solid or surface by an arbitrary plane. All + /// geometry, persistence records, and displays are prepared before the + /// first source entity is changed. + pub(super) fn solid_slice( + &mut self, + requested: &[Handle], + plane: cadkernel::space::Plane, + keep_point: Option, + ) -> Task { + self.solid_slice_with_tool(requested, ModelSliceTool::Plane(plane), keep_point) + } + + pub(super) fn solid_slice_surface( + &mut self, + requested: &[Handle], + cutter: Body, + keep_point: Option, + ) -> Task { + self.solid_slice_with_tool(requested, ModelSliceTool::Surface(cutter), keep_point) + } + + fn solid_slice_with_tool( + &mut self, + requested: &[Handle], + tool: ModelSliceTool, + keep_point: Option, + ) -> Task { + let i = self.active_tab; + let mut handles = requested + .iter() + .copied() + .filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle)) + .filter(|handle| { + matches!( + self.tabs[i].scene.document.get_entity(*handle), + Some(EntityType::Solid3D(_) | EntityType::Surface(_)) + ) + }) + .collect::>(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + self.tabs[i].scene.restore_solid_models(&handles); + handles.retain(|handle| self.tabs[i].scene.solid_models.contains_key(handle)); + if handles.is_empty() { + self.command_line.push_error( + crate::t!("SLICE: select at least one solid or surface.").as_ref(), + ); + return Task::none(); + } + + let keep_positive = if let Some(point) = keep_point { + let tolerance = cadkernel::brep::operation_tolerance( + &handles + .iter() + .map(|handle| &self.tabs[i].scene.solid_models[handle]) + .collect::>(), + ); + let Some(distance) = tool.side(point.to_array()) else { + self.command_line + .push_error(crate::t!("SLICE: the cutting plane is invalid.").as_ref()); + return Task::none(); + }; + if distance.abs() <= tolerance { + self.command_line.push_error( + crate::t!("SLICE: choose a point away from the cutting plane.").as_ref(), + ); + return Task::none(); + } + Some(distance > 0.0) + } else { + None + }; + + let mut prepared = Vec::with_capacity(handles.len()); + for handle in &handles { + let body = &self.tabs[i].scene.solid_models[handle]; + let result = match tool.split(body) { + Ok(Some(result)) => result, + Ok(None) => { + self.command_line.push_error( + crate::t!("SLICE: the cutting plane does not cross every selected object; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::NoClosedForm) => { + self.command_line.push_error( + crate::t!("SLICE: the selected geometry has an unsupported surface intersection; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::Coincident) => { + self.command_line.push_error( + crate::t!("SLICE: the cutting plane is coincident with source geometry; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + Err(cadkernel::brep::Snag::CutRefused) => { + self.command_line.push_error( + crate::t!("SLICE: the selected topology could not be split safely; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + }; + let (body, extra_body) = match keep_positive { + Some(true) => (result.positive, None), + Some(false) => (result.negative, None), + None => (result.negative, Some(result.positive)), + }; + let Some(source) = self.tabs[i].scene.document.get_entity(*handle).cloned() else { + return Task::none(); + }; + let Some(entity) = entity_with_boolean_body(source.clone(), &body) else { + self.command_line.push_error( + crate::t!("SLICE: a result could not be encoded; no object was changed.") + .as_ref(), + ); + return Task::none(); + }; + let Some(display) = self.tabs[i] + .scene + .prepare_solid_model_display(*handle, &body) + .filter(|display| display.0.complete) + else { + self.command_line.push_error( + crate::t!("SLICE: a result could not be displayed completely; no object was changed.") + .as_ref(), + ); + return Task::none(); + }; + let extra = match extra_body { + Some(extra_body) => { + let mut extra_entity = match entity_with_boolean_body(source, &extra_body) { + Some(entity) => entity, + None => { + self.command_line.push_error( + crate::t!("SLICE: a result could not be encoded; no object was changed.") + .as_ref(), + ); + return Task::none(); + } + }; + extra_entity.common_mut().handle = Handle::NULL; + Some((extra_entity, extra_body)) + } + None => None, + }; + prepared.push(PreparedSlice { + retained: *handle, + entity, + body, + display, + extra, + }); + } + + self.push_undo_snapshot(i, "SLICE"); + let mut created = Vec::new(); + for item in &prepared { + let Some((entity, body)) = &item.extra else { + continue; + }; + let handle = match entity { + EntityType::Solid3D(_) => self.add_solid_model_preserving_style( + entity.clone(), + body.clone(), + solid_history::brep_op(body), + ), + EntityType::Surface(_) => { + self.add_surface_model_preserving_style(entity.clone(), body.clone()) + } + _ => Handle::NULL, + }; + if handle.is_null() { + self.tabs[i].scene.rollback_new_entities(&created); + self.discard_last_undo_entry(i); + self.command_line.push_error( + crate::t!("SLICE: a result could not be committed; all source objects were retained.") + .as_ref(), + ); + return Task::none(); + } + created.push(handle); + } + + let record_history = self.tabs[i].scene.document.header.record_solid_history; + let mut retained = Vec::with_capacity(prepared.len()); + for item in prepared { + self.tabs[i].scene.delete_solid_history(item.retained); + if !self.tabs[i].scene.update_entity(item.entity) { + self.command_line.push_error( + crate::t!("SLICE: a retained result could not be updated.").as_ref(), + ); + return Task::none(); + } + if record_history + && matches!( + self.tabs[i].scene.document.get_entity(item.retained), + Some(EntityType::Solid3D(_)) + ) + { + self.tabs[i] + .scene + .create_solid_history(item.retained, solid_history::brep_op(&item.body)); + } + self.tabs[i].scene.register_prepared_solid_model( + item.retained, + item.body, + item.display, + ); + retained.push(item.retained); + } + + retained.extend(created); + self.tabs[i].scene.deselect_all(); + for handle in &retained { + self.tabs[i].scene.select_entity(*handle, false); + } + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + crate::tf!( + "SLICE: created %{count} result object(s).", + count = retained.len() + ) + .as_ref(), + ); + Task::none() + } + + pub(super) fn slice_selected( + &mut self, + plane: cadkernel::space::Plane, + keep_point: Option, + ) -> Task { + let handles = self.tabs[self.active_tab].scene.selected_handles_in_order(); + self.solid_slice(&handles, plane, keep_point) + } + /// INTERFERE — create a solid from the overlap of the two selected solids, /// leaving the originals in place (a non-destructive boolean intersect). pub(super) fn solid_interfere(&mut self) -> Task { diff --git a/src/app/properties.rs b/src/app/properties.rs index 55e291820..a72daeaf4 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -440,15 +440,15 @@ impl OpenCADStudio { let display_entity = dispatch::entity_in_working_plane(contextual.as_ref(), plane); let entity = &display_entity; let group_names = self.tabs[i].scene.group_names_for_entity(handle); - let specialized_primitive = - crate::scene::model::solid_history::has_specialized_primitive_properties( + let compact_solid = + crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, handle, ); let mut sections = dispatch::properties_sectioned(handle, entity, &text_style_names); - if specialized_primitive { - retain_specialized_primitive_sections(&mut sections); + if compact_solid { + retain_compact_solid_sections(&mut sections); } sections.extend( crate::scene::model::solid_history::primitive_properties( @@ -636,7 +636,7 @@ impl OpenCADStudio { } } - if !specialized_primitive && matches!( + if matches!( entity, acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Region(_) @@ -654,38 +654,44 @@ impl OpenCADStudio { { use crate::entities::common::ro_prop; let metrics = mesh.metrics; - let mut props = vec![ - ro_prop( - t!("Vertices").as_ref(), - "mesh_vertices", - metrics.vertices.to_string(), - ), - ro_prop( - t!("Triangles").as_ref(), - "mesh_triangles", - metrics.triangles.to_string(), - ), - ro_prop( - t!("Surface Area").as_ref(), - "mesh_surface_area", - format!("{:.6}", metrics.surface_area), - ), - ro_prop( + let triple = |values: [f64; 3]| { + format!("{:.6}, {:.6}, {:.6}", values[0], values[1], values[2]) + }; + let mut props = if compact_solid { + vec![ro_prop( t!("Centroid").as_ref(), "mesh_centroid", - format!( - "{:.6}, {:.6}, {:.6}", - metrics.centroid[0], - metrics.centroid[1], - metrics.centroid[2] + triple(metrics.centroid), + )] + } else { + vec![ + ro_prop( + t!("Vertices").as_ref(), + "mesh_vertices", + metrics.vertices.to_string(), ), - ), - ro_prop( - t!("Tessellation").as_ref(), - "mesh_complete", - if mesh.complete { "Complete" } else { "Partial" }, - ), - ]; + ro_prop( + t!("Triangles").as_ref(), + "mesh_triangles", + metrics.triangles.to_string(), + ), + ro_prop( + t!("Surface Area").as_ref(), + "mesh_surface_area", + format!("{:.6}", metrics.surface_area), + ), + ro_prop( + t!("Centroid").as_ref(), + "mesh_centroid", + triple(metrics.centroid), + ), + ro_prop( + t!("Tessellation").as_ref(), + "mesh_complete", + if mesh.complete { "Complete" } else { "Partial" }, + ), + ] + }; if mesh.complete && matches!( entity, @@ -694,14 +700,50 @@ impl OpenCADStudio { | acadrust::EntityType::Body(_) ) { - props.insert( - 3, + let closed_props = vec![ + ro_prop( + t!("Moment of inertia").as_ref(), + "mesh_moment_of_inertia", + triple(metrics.moment_of_inertia), + ), + ro_prop( + t!("Principal directions").as_ref(), + "mesh_principal_directions", + metrics + .principal_directions + .chunks_exact(3) + .map(|axis| format!("({:.6}, {:.6}, {:.6})", axis[0], axis[1], axis[2])) + .collect::>() + .join(", "), + ), + ro_prop( + t!("Principal moments").as_ref(), + "mesh_principal_moments", + triple(metrics.principal_moments), + ), + ro_prop( + t!("Product of inertia").as_ref(), + "mesh_product_of_inertia", + triple(metrics.product_of_inertia), + ), + ro_prop( + t!("Radii of gyration").as_ref(), + "mesh_radii_of_gyration", + triple(metrics.radii_of_gyration), + ), ro_prop( t!("Volume").as_ref(), "mesh_volume", format!("{:.6}", metrics.volume), ), - ); + ]; + if compact_solid { + props.extend(closed_props); + } else { + let volume = closed_props.last().cloned().unwrap(); + props.insert(3, volume); + props.extend(closed_props.into_iter().take(5)); + } } sections.push(crate::scene::model::object::PropSection { title: t!("Mass Properties").into_owned(), @@ -710,7 +752,7 @@ impl OpenCADStudio { } } - if !specialized_primitive { + if !compact_solid { sections.extend(crate::entities::object_data::sections( &self.tabs[i].scene.document, &self.tabs[i].scene.object_data_cache, @@ -2068,8 +2110,8 @@ impl OpenCADStudio { }); } } - if specialized_primitive { - retain_specialized_primitive_sections(&mut sections); + if compact_solid { + retain_compact_solid_sections(&mut sections); } let title = match entity { acadrust::EntityType::Insert(ins) => { @@ -2166,7 +2208,7 @@ impl OpenCADStudio { .collect(); let mut sections = aggregate_sections(&local_refs, &text_style_names); if local_refs.iter().all(|(handle, _)| { - crate::scene::model::solid_history::has_specialized_primitive_properties( + crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, *handle, ) @@ -2591,7 +2633,7 @@ impl OpenCADStudio { &mut self, entity: acadrust::EntityType, ) -> Option { - self.commit_entity_handle_with_policies(entity, false, false) + self.commit_entity_handle_with_policies(entity, false, false, false) } pub(super) fn commit_entity_handle_with_dimension_policy( @@ -2603,6 +2645,7 @@ impl OpenCADStudio { entity, preserve_dimension_layer_and_style, false, + false, ) } @@ -2610,7 +2653,14 @@ impl OpenCADStudio { &mut self, entity: acadrust::EntityType, ) -> Option { - self.commit_entity_handle_with_policies(entity, false, true) + self.commit_entity_handle_with_policies(entity, false, true, false) + } + + pub(super) fn commit_entity_handle_preserve_style( + &mut self, + entity: acadrust::EntityType, + ) -> Option { + self.commit_entity_handle_with_policies(entity, false, true, true) } fn commit_entity_handle_with_policies( @@ -2618,6 +2668,7 @@ impl OpenCADStudio { mut entity: acadrust::EntityType, preserve_dimension_layer_and_style: bool, preserve_entity_layer: bool, + preserve_entity_style: bool, ) -> Option { let i = self.active_tab; let tracks_dimension_chain = matches!( @@ -2735,25 +2786,32 @@ impl OpenCADStudio { } } - crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); - crate::scene::view::dispatch::apply_common_prop( - &mut entity, - "linetype", - &self.ribbon.active_linetype.clone(), - ); - crate::scene::view::dispatch::apply_line_weight(&mut entity, self.ribbon.active_lineweight); - // CELTSCALE (header.current_entity_linetype_scale): new entities - // pick up the document's saved per-entity linetype scale. The user - // can override per entity later via the properties panel. - let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale; - if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { - entity.common_mut().linetype_scale = celtscale; + if !preserve_entity_style { + crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color); + crate::scene::view::dispatch::apply_common_prop( + &mut entity, + "linetype", + &self.ribbon.active_linetype.clone(), + ); + crate::scene::view::dispatch::apply_line_weight( + &mut entity, + self.ribbon.active_lineweight, + ); + // CELTSCALE (header.current_entity_linetype_scale): new entities + // pick up the document's saved per-entity linetype scale. The user + // can override per entity later via the properties panel. + let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale; + if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 { + entity.common_mut().linetype_scale = celtscale; + } } - crate::scene::creation_style::apply_current_creation_styles( - &self.tabs[i].scene.document, - &mut entity, - ); + if !preserve_entity_style { + crate::scene::creation_style::apply_current_creation_styles( + &self.tabs[i].scene.document, + &mut entity, + ); + } if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) = (inherited_dimension, &mut entity) { @@ -3061,7 +3119,7 @@ fn aggregate_solid_history_sections( merged } -fn retain_specialized_primitive_sections( +fn retain_compact_solid_sections( sections: &mut Vec, ) { sections.iter_mut().for_each(|section| { @@ -3077,7 +3135,8 @@ fn retain_specialized_primitive_sections( | "transparency" | "hyperlink" | "material" - ) || crate::scene::model::solid_history::is_specialized_property(property.field) + ) || property.field.starts_with("mesh_") + || crate::scene::model::solid_history::is_specialized_property(property.field) }); }); sections.retain(|section| !section.props.is_empty()); diff --git a/src/app/record_api.rs b/src/app/record_api.rs new file mode 100644 index 000000000..843c04802 --- /dev/null +++ b/src/app/record_api.rs @@ -0,0 +1,1875 @@ +use acadrust::tables::{Table, TableEntry}; +use iced::Task; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::{Map, Value, json}; +use std::collections::VecDeque; +use std::sync::OnceLock; + +use super::{Message, OpenCADStudio}; + +const COLLECTIONS: &[(&str, bool)] = &[ + ("entities", true), + ("objects", true), + ("layers", true), + ("line_types", true), + ("text_styles", true), + ("block_records", true), + ("dim_styles", true), + ("app_ids", true), + ("views", true), + ("vports", true), + ("ucss", true), + ("vx_table", true), + ("header", true), + ("summary_info", true), + ("classes", false), + ("block_visibility", false), + ("context_scales", false), + ("block_representations", false), + ("fields", false), + ("dgn_line_style_definitions", false), + ("dgn_line_style_components", false), + ("vx_control_entries", false), + ("section_view_style", false), + ("view_rep_references", false), + ("section_view_representations", false), + ("notifications", false), + ("preview", false), + ("document", false), +]; + +fn collection_root_type(collection: &str) -> Option<&'static str> { + Some(match collection { + "entities" => "EntityType", + "objects" => "ObjectType", + "layers" => "Layer", + "line_types" => "LineType", + "text_styles" => "TextStyle", + "block_records" => "BlockRecord", + "dim_styles" => "DimStyle", + "app_ids" => "AppId", + "views" => "View", + "vports" => "VPort", + "ucss" => "Ucs", + "vx_table" => "VxTableRecord", + "header" => "HeaderVariables", + "summary_info" => "SummaryInfo", + "classes" => "DxfClass", + "block_visibility" => "BlockVisibilityParameter", + "context_scales" + | "block_representations" + | "vx_control_entries" + | "section_view_representations" + | "view_rep_references" => "Handle", + "fields" => "FieldDef", + "dgn_line_style_definitions" => "DgnLsDefinition", + "dgn_line_style_components" => "DgnLsComponent", + "notifications" => "NotificationCollection", + "preview" => "Preview", + "section_view_style" => "EntitySectionViewStyle", + // This aggregate is assembled by OCS and has a live inferred schema. + "document" => "Value", + _ => return None, + }) +} + +fn collection_is_mutable(collection: &str) -> bool { + COLLECTIONS + .iter() + .find(|(name, _)| *name == collection) + .is_some_and(|(_, mutable)| *mutable) +} + +fn collection_record_name(collection: &str) -> Option<&'static str> { + Some(match collection { + "classes" => "Class", + "fields" => "Field", + "dgn_line_style_definitions" => "LineStyleDefinition", + "dgn_line_style_components" => "LineStyleComponent", + "section_view_style" => "SectionViewStyle", + "view_rep_references" => "HandleReferences", + "notifications" => "Notifications", + "document" => "Document", + _ => collection_root_type(collection)?, + }) +} + +fn identity_paths(collection: &str) -> &'static [&'static str] { + match collection { + "entities" => &["/common/handle", "/common/owner_handle"], + "objects" => &[ + "/handle", + "/owner", + "/common/handle", + "/common/owner_handle", + ], + "header" => &["/handle_seed"], + "summary_info" => &[], + "layers" | "line_types" | "text_styles" | "block_records" | "dim_styles" | "app_ids" + | "views" | "vports" | "ucss" | "vx_table" => &["/handle", "/name"], + _ => &[], + } +} + +fn path_changes_identity(collection: &str, path: &str) -> bool { + path_is_identity_field(collection, path) + || identity_paths(collection).iter().any(|identity| { + path == *identity + || identity + .strip_prefix(path) + .is_some_and(|suffix| suffix.starts_with('/')) + }) +} + +fn path_is_identity_field(collection: &str, path: &str) -> bool { + match collection { + "entities" => path.ends_with("/common/handle") || path.ends_with("/common/owner_handle"), + "objects" => { + matches!(path, "/handle" | "/owner") + || path.ends_with("/common/handle") + || path.ends_with("/common/owner_handle") + } + _ => identity_paths(collection).contains(&path), + } +} + +fn collect_identity_values(value: &Value, collection: &str, path: &str, output: &mut Vec) { + if path_is_identity_field(collection, path) { + output.push(json!({"path":path,"value":value})); + } + match value { + Value::Object(object) => { + for (name, value) in object { + collect_identity_values( + value, + collection, + &format!("{path}/{}", json_pointer_name(name)), + output, + ); + } + } + Value::Array(values) => { + for (index, value) in values.iter().enumerate() { + collect_identity_values(value, collection, &format!("{path}/{index}"), output); + } + } + _ => {} + } +} + +fn registry_types() -> &'static Map { + static REGISTRY: OnceLock = OnceLock::new(); + REGISTRY.get_or_init(|| { + serde_json::from_str(ocs_plugin_api::get_embedded_type_registry_json()) + .expect("embedded type registry") + })["types"] + .as_object() + .expect("type registry object") +} + +fn failure(code: &str, message: impl Into) -> Value { + json!({"ok":false,"status":"failed","code":code,"error":message.into()}) +} + +fn json_value(value: &impl Serialize) -> Result { + serde_json::to_value(value).map_err(|error| error.to_string()) +} + +fn enum_parts(value: &impl Serialize) -> Result<(String, Value), String> { + let Value::Object(wrapper) = json_value(value)? else { + return Err("record type is not represented by an object".into()); + }; + if wrapper.len() != 1 { + return Err("record type has an invalid representation".into()); + } + Ok(wrapper.into_iter().next().expect("one entry")) +} + +fn decode_enum(kind: &str, properties: Value) -> Result { + let mut wrapper = Map::new(); + wrapper.insert(kind.to_string(), properties); + serde_json::from_value(Value::Object(wrapper)).map_err(|error| error.to_string()) +} + +fn handle_text(handle: acadrust::Handle) -> String { + format!("{:X}", handle.value()) +} + +fn without_hex_prefix(value: &str) -> &str { + value + .strip_prefix("0x") + .or_else(|| value.strip_prefix("0X")) + .unwrap_or(value) +} + +fn record( + collection: &str, + kind: &str, + handle: Option, + name: Option<&str>, + mutable: bool, + properties: Value, +) -> Value { + let mut value = json!({ + "collection":collection, + "type":kind, + "mutable":mutable, + "properties":properties, + }); + let object = value.as_object_mut().expect("record object"); + if let Some(handle) = handle { + object.insert("handle".into(), json!(handle_text(handle))); + } + if let Some(name) = name { + object.insert("name".into(), json!(name)); + } + value +} + +fn entity_record(entity: &acadrust::EntityType) -> Result { + let (kind, properties) = enum_parts(entity)?; + let mut value = record( + "entities", + &kind, + Some(entity.common().handle), + None, + true, + properties, + ); + let object = value.as_object_mut().expect("record object"); + object.insert( + "display_type".into(), + json!(crate::entities::names::ui_name(entity)), + ); + object.insert( + "record_type".into(), + json!(crate::entities::names::dxf_name(entity)), + ); + Ok(value) +} + +fn object_record( + handle: acadrust::Handle, + object: &acadrust::objects::ObjectType, +) -> Result { + let (kind, properties) = enum_parts(object)?; + Ok(record( + "objects", + &kind, + Some(handle), + None, + true, + properties, + )) +} + +fn table_records( + collection: &str, + kind: &str, + table: &Table, +) -> Result, String> { + table + .iter() + .map(|entry| { + Ok(record( + collection, + kind, + Some(entry.handle()), + Some(entry.name()), + true, + json_value(entry)?, + )) + }) + .collect() +} + +fn map_records( + collection: &str, + kind: &str, + values: &std::collections::HashMap, +) -> Result, String> { + let mut entries: Vec<_> = values.iter().collect(); + entries.sort_by_key(|(handle, _)| handle.value()); + entries + .into_iter() + .map(|(handle, value)| { + Ok(record( + collection, + kind, + Some(*handle), + None, + false, + json_value(value)?, + )) + }) + .collect() +} + +fn collection_records( + document: &acadrust::CadDocument, + collection: &str, +) -> Result, String> { + match collection { + "entities" => document.entities().map(entity_record).collect(), + "objects" => { + let mut objects: Vec<_> = document.objects.iter().collect(); + objects.sort_by_key(|(handle, _)| handle.value()); + objects + .into_iter() + .map(|(handle, object)| object_record(*handle, object)) + .collect() + } + "layers" => table_records("layers", "Layer", &document.layers), + "line_types" => table_records("line_types", "LineType", &document.line_types), + "text_styles" => table_records("text_styles", "TextStyle", &document.text_styles), + "block_records" => table_records("block_records", "BlockRecord", &document.block_records), + "dim_styles" => table_records("dim_styles", "DimStyle", &document.dim_styles), + "app_ids" => table_records("app_ids", "AppId", &document.app_ids), + "views" => table_records("views", "View", &document.views), + "vports" => table_records("vports", "VPort", &document.vports), + "ucss" => table_records("ucss", "Ucs", &document.ucss), + "vx_table" => table_records("vx_table", "VxTableRecord", &document.vx_table), + "header" => Ok(vec![record( + "header", + "HeaderVariables", + None, + Some("header"), + true, + json_value(&document.header)?, + )]), + "summary_info" => Ok(vec![record( + "summary_info", + "SummaryInfo", + None, + Some("summary_info"), + true, + json_value(&document.summary_info)?, + )]), + "classes" => document + .classes + .iter() + .map(|value| { + Ok(record( + "classes", + "Class", + None, + Some(&value.dxf_name), + false, + json_value(value)?, + )) + }) + .collect(), + "block_visibility" => map_records( + "block_visibility", + "BlockVisibilityParameter", + &document.block_visibility_params, + ), + "context_scales" => map_records("context_scales", "Handle", &document.context_scales), + "block_representations" => map_records( + "block_representations", + "Handle", + &document.block_representations, + ), + "fields" => map_records("fields", "Field", &document.fields), + "dgn_line_style_definitions" => map_records( + "dgn_line_style_definitions", + "LineStyleDefinition", + &document.dgn_ls_definitions, + ), + "dgn_line_style_components" => map_records( + "dgn_line_style_components", + "LineStyleComponent", + &document.dgn_ls_components, + ), + "vx_control_entries" => Ok(document + .vx_control_entries + .iter() + .map(|handle| { + record( + "vx_control_entries", + "Handle", + Some(*handle), + None, + false, + json!({}), + ) + }) + .collect()), + "section_view_style" => Ok(document + .section_view_style + .as_ref() + .map(|value| { + record( + "section_view_style", + "SectionViewStyle", + None, + Some("section_view_style"), + false, + json_value(value).unwrap_or(Value::Null), + ) + }) + .into_iter() + .collect()), + "view_rep_references" => map_records( + "view_rep_references", + "HandleReferences", + &document.view_rep_refs, + ), + "section_view_representations" => Ok(document + .section_view_reps + .iter() + .map(|handle| { + record( + "section_view_representations", + "Handle", + Some(*handle), + None, + false, + json!({}), + ) + }) + .collect()), + "notifications" => Ok(vec![record( + "notifications", + "Notifications", + None, + Some("notifications"), + false, + json_value(&document.notifications)?, + )]), + "preview" => Ok(document + .preview + .as_ref() + .map(|value| { + record( + "preview", + "Preview", + None, + Some("preview"), + false, + json_value(value).unwrap_or(Value::Null), + ) + }) + .into_iter() + .collect()), + "document" => Ok(vec![record( + "document", + "Document", + None, + Some("document"), + false, + json!({ + "version":document.version, + "maintenance_version":document.maintenance_version, + "source_path":document.source_path, + "source_version":document.dwg_source_version, + }), + )]), + _ => Err(format!("unknown record collection: {collection}")), + } +} + +fn collection_count(document: &acadrust::CadDocument, collection: &str) -> usize { + match collection { + "entities" => document.entities().count(), + "objects" => document.objects.len(), + "layers" => document.layers.len(), + "line_types" => document.line_types.len(), + "text_styles" => document.text_styles.len(), + "block_records" => document.block_records.len(), + "dim_styles" => document.dim_styles.len(), + "app_ids" => document.app_ids.len(), + "views" => document.views.len(), + "vports" => document.vports.len(), + "ucss" => document.ucss.len(), + "vx_table" => document.vx_table.len(), + "classes" => document.classes.len(), + "block_visibility" => document.block_visibility_params.len(), + "context_scales" => document.context_scales.len(), + "block_representations" => document.block_representations.len(), + "fields" => document.fields.len(), + "dgn_line_style_definitions" => document.dgn_ls_definitions.len(), + "dgn_line_style_components" => document.dgn_ls_components.len(), + "vx_control_entries" => document.vx_control_entries.len(), + "section_view_style" => usize::from(document.section_view_style.is_some()), + "view_rep_references" => document.view_rep_refs.len(), + "section_view_representations" => document.section_view_reps.len(), + "preview" => usize::from(document.preview.is_some()), + _ => 1, + } +} + +fn compare( + actual: Option<&Value>, + operator: &str, + expected: Option<&Value>, +) -> Result { + let exists = actual.is_some(); + if operator == "exists" { + return Ok(exists); + } + if operator == "not_exists" { + return Ok(!exists); + } + let Some(actual) = actual else { + return Ok(false); + }; + let expected = expected.ok_or_else(|| format!("filter operator {operator} requires value"))?; + Ok(match operator { + "eq" => actual == expected, + "ne" => actual != expected, + "lt" | "lte" | "gt" | "gte" => { + let left = actual + .as_f64() + .ok_or_else(|| format!("filter operator {operator} requires numeric values"))?; + let right = expected + .as_f64() + .ok_or_else(|| format!("filter operator {operator} requires numeric values"))?; + match operator { + "lt" => left < right, + "lte" => left <= right, + "gt" => left > right, + _ => left >= right, + } + } + "contains" => match (actual, expected) { + (Value::String(left), Value::String(right)) => left.contains(right), + (Value::Array(values), expected) => values.contains(expected), + _ => return Err("contains requires a string/string or array/value pair".into()), + }, + "starts_with" => actual + .as_str() + .zip(expected.as_str()) + .is_some_and(|(left, right)| left.starts_with(right)), + "ends_with" => actual + .as_str() + .zip(expected.as_str()) + .is_some_and(|(left, right)| left.ends_with(right)), + "in" => expected + .as_array() + .is_some_and(|values| values.contains(actual)), + _ => return Err(format!("unknown filter operator: {operator}")), + }) +} + +fn matches_filters(record: &Value, request: &Value) -> Result { + if request["handle"].as_str().is_some_and(|requested| { + !record["handle"] + .as_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(without_hex_prefix(requested))) + }) { + return Ok(false); + } + if let Some(handles) = request["handles"].as_array() { + let Some(actual) = record["handle"].as_str() else { + return Ok(false); + }; + if !handles + .iter() + .filter_map(Value::as_str) + .any(|requested| actual.eq_ignore_ascii_case(without_hex_prefix(requested))) + { + return Ok(false); + } + } + if request["type"].as_str().is_some_and(|requested| { + !["type", "display_type", "record_type"].iter().any(|key| { + record[*key] + .as_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(requested)) + }) + }) { + return Ok(false); + } + if request["name"].as_str().is_some_and(|requested| { + !record["name"] + .as_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(requested)) + }) { + return Ok(false); + } + if let Some(filters) = request["where"].as_array() { + for filter in filters { + let path = filter["path"] + .as_str() + .ok_or_else(|| "each filter requires a JSON Pointer path".to_string())?; + if !path.is_empty() && !path.starts_with('/') { + return Err(format!("invalid JSON Pointer: {path}")); + } + let actual = if path.is_empty() { + Some(&record["properties"]) + } else { + record["properties"].pointer(path) + }; + let operator = filter["op"].as_str().unwrap_or("eq"); + if !compare(actual, operator, filter.get("value"))? { + return Ok(false); + } + } + } + Ok(true) +} + +fn project_paths(mut record: Value, paths: Option<&Vec>) -> Result { + let Some(paths) = paths else { + return Ok(record); + }; + let mut projected = Map::new(); + for path in paths { + let path = path + .as_str() + .ok_or_else(|| "paths must contain JSON Pointer strings".to_string())?; + if !path.is_empty() && !path.starts_with('/') { + return Err(format!("invalid JSON Pointer: {path}")); + } + let value = if path.is_empty() { + Some(&record["properties"]) + } else { + record["properties"].pointer(path) + }; + projected.insert(path.to_string(), value.cloned().unwrap_or(Value::Null)); + } + let object = record.as_object_mut().expect("record object"); + object.remove("properties"); + object.insert("values".into(), Value::Object(projected)); + Ok(record) +} + +fn resolved_type_name(requested: &str) -> Option { + let types = registry_types(); + types + .get_key_value(requested) + .or_else(|| { + types + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(requested)) + }) + .map(|(name, _)| name.clone()) +} + +fn collect_type_references(value: &Value, pending: &mut VecDeque) { + match value { + Value::Object(object) => { + for (key, value) in object { + if key == "type_id" { + if let Some(type_id) = value.as_str() { + for token in type_id.split(|character: char| { + !character.is_ascii_alphanumeric() && character != '_' + }) { + if registry_types().contains_key(token) { + pending.push_back(token.to_string()); + } + } + } + } + collect_type_references(value, pending); + } + } + Value::Array(values) => { + for value in values { + collect_type_references(value, pending); + } + } + _ => {} + } +} + +fn referenced_definitions(root: &str) -> Map { + let mut definitions = Map::new(); + let mut pending = VecDeque::from([root.to_string()]); + while let Some(name) = pending.pop_front() { + if definitions.contains_key(&name) { + continue; + } + let Some(info) = registry_types().get(&name) else { + continue; + }; + collect_type_references(info, &mut pending); + definitions.insert(name, info.clone()); + } + definitions +} + +fn json_pointer_name(name: &str) -> String { + name.replace('~', "~0").replace('/', "~1") +} + +fn field_unit(name: &str, type_id: &str) -> Option<&'static str> { + let numeric = matches!( + type_id, + "f32" + | "f64" + | "i8" + | "i16" + | "i32" + | "i64" + | "u8" + | "u16" + | "u32" + | "u64" + | "Vector2" + | "Vector3" + ); + if !numeric { + return None; + } + let name = name.to_ascii_lowercase(); + if name.contains("angle") || name == "rotation" || name.contains("twist") { + return Some("radians"); + } + if name.contains("scale") || name.contains("ratio") || name.contains("factor") { + return Some("unitless"); + } + if [ + "point", + "position", + "origin", + "center", + "location", + "elevation", + "height", + "width", + "length", + "radius", + "diameter", + "offset", + "thickness", + "distance", + "size", + "spacing", + ] + .iter() + .any(|part| name.contains(part)) + { + return Some("drawing_units"); + } + None +} + +fn type_constraints(type_id: &str) -> Option { + let integer_bounds = match type_id { + "i8" => Some(json!({"minimum":i8::MIN,"maximum":i8::MAX})), + "i16" => Some(json!({"minimum":i16::MIN,"maximum":i16::MAX})), + "i32" => Some(json!({"minimum":i32::MIN,"maximum":i32::MAX})), + "i64" => Some(json!({"minimum":i64::MIN,"maximum":i64::MAX})), + "u8" => Some(json!({"minimum":0,"maximum":u8::MAX})), + "u16" => Some(json!({"minimum":0,"maximum":u16::MAX})), + "u32" => Some(json!({"minimum":0,"maximum":u32::MAX})), + "u64" | "Handle" => Some(json!({"minimum":0,"maximum":u64::MAX})), + _ => None, + }; + if integer_bounds.is_some() { + return integer_bounds; + } + let info = registry_types().get(type_id)?; + if info["kind"] != "Enum" { + return None; + } + Some(json!({ + "allowed_variants":info["variants"].as_array().into_iter().flatten() + .filter_map(|variant|variant["name"].as_str()).collect::>() + })) +} + +fn append_schema_fields( + owner_type: &str, + type_fields: &[Value], + prefix: &str, + collection: Option<&str>, + depth: usize, + stack: &mut Vec, + fields: &mut Vec, +) { + for field in type_fields { + let Some(name) = field["name"].as_str() else { + continue; + }; + let type_id = field["type_id"].as_str().unwrap_or("Value"); + let path = format!("{prefix}/{}", json_pointer_name(name)); + let sequence = field["is_sequence"].as_bool().unwrap_or(false); + let writable = collection.is_some_and(collection_is_mutable) + && collection.is_none_or(|collection| !path_changes_identity(collection, &path)); + let mut description = json!({ + "path":path, + "name":name, + "description":format!("{} property on {owner_type}", name.replace('_', " ")), + "type":type_id, + "optional":field["optional"].as_bool().unwrap_or(false), + "sequence":sequence, + "writable":writable, + }); + let object = description.as_object_mut().expect("field description"); + if sequence { + object.insert( + "item_path_template".into(), + json!(format!("{path}/{{index}}")), + ); + } + if let Some(unit) = field_unit(name, type_id) { + object.insert("unit".into(), json!(unit)); + } + if let Some(constraints) = type_constraints(type_id) { + object.insert("constraints".into(), constraints); + } + fields.push(description); + + let nested_prefix = if sequence { + format!("{path}/{{index}}") + } else { + path + }; + if registry_types().contains_key(type_id) { + append_field_descriptions( + type_id, + &nested_prefix, + collection, + depth + 1, + stack, + fields, + ); + } + } +} + +fn append_field_descriptions( + type_name: &str, + prefix: &str, + collection: Option<&str>, + depth: usize, + stack: &mut Vec, + fields: &mut Vec, +) { + if depth > 12 || stack.iter().any(|name| name == type_name) { + return; + } + let Some(info) = registry_types().get(type_name) else { + return; + }; + stack.push(type_name.to_string()); + match info["kind"].as_str() { + Some("Struct") => append_schema_fields( + type_name, + info["fields"].as_array().map(Vec::as_slice).unwrap_or(&[]), + prefix, + collection, + depth, + stack, + fields, + ), + Some("Enum") => append_enum_descriptions( + type_name, + info["variants"] + .as_array() + .map(Vec::as_slice) + .unwrap_or(&[]), + prefix, + collection, + depth, + stack, + fields, + ), + _ => {} + } + stack.pop(); +} + +fn append_enum_descriptions( + enum_type: &str, + variants: &[Value], + prefix: &str, + collection: Option<&str>, + depth: usize, + stack: &mut Vec, + fields: &mut Vec, +) { + for variant in variants { + let Some(variant_name) = variant["name"].as_str() else { + continue; + }; + let Some(variant_fields) = variant["fields"] + .as_array() + .filter(|fields| !fields.is_empty()) + else { + continue; + }; + let path = format!("{prefix}/{}", json_pointer_name(variant_name)); + let newtype = (variant_fields.len() == 1 && variant_fields[0]["name"] == "0") + .then(|| &variant_fields[0]); + let type_id = newtype + .and_then(|field| field["type_id"].as_str()) + .unwrap_or("struct_variant"); + let sequence = newtype + .and_then(|field| field["is_sequence"].as_bool()) + .unwrap_or(false); + let writable = collection.is_some_and(collection_is_mutable) + && collection.is_none_or(|collection| !path_changes_identity(collection, &path)); + let mut description = json!({ + "path":path, + "name":variant_name, + "description":format!("{variant_name} variant on {enum_type}"), + "type":type_id, + "enum":enum_type, + "variant":variant_name, + "optional":false, + "sequence":sequence, + "writable":writable, + }); + if sequence { + description + .as_object_mut() + .expect("variant description") + .insert( + "item_path_template".into(), + json!(format!("{path}/{{index}}")), + ); + } + if let Some(unit) = field_unit(variant_name, type_id) { + description + .as_object_mut() + .expect("variant description") + .insert("unit".into(), json!(unit)); + } + if let Some(constraints) = type_constraints(type_id) { + description + .as_object_mut() + .expect("variant description") + .insert("constraints".into(), constraints); + } + fields.push(description); + + if newtype.is_some() { + if registry_types().contains_key(type_id) { + let nested_prefix = if sequence { + format!("{path}/{{index}}") + } else { + path + }; + append_field_descriptions( + type_id, + &nested_prefix, + collection, + depth + 1, + stack, + fields, + ); + } + } else { + append_schema_fields( + variant_name, + variant_fields, + &path, + collection, + depth + 1, + stack, + fields, + ); + } + } +} + +fn append_variant_field_descriptions( + enum_type: &str, + variant_name: &str, + collection: Option<&str>, + fields: &mut Vec, +) { + let Some(variant_fields) = registry_types() + .get(enum_type) + .and_then(|info| info["variants"].as_array()) + .and_then(|variants| { + variants.iter().find(|variant| { + variant["name"] + .as_str() + .is_some_and(|name| name.eq_ignore_ascii_case(variant_name)) + }) + }) + .and_then(|variant| variant["fields"].as_array()) + else { + return; + }; + let mut stack = vec![enum_type.to_string()]; + append_schema_fields( + variant_name, + variant_fields, + "", + collection, + 0, + &mut stack, + fields, + ); +} + +fn runtime_value_schema(value: &Value, collection: &str, path: &str) -> Value { + let mut schema = match value { + Value::Null => json!({"type":"null"}), + Value::Bool(value) => json!({"type":"boolean","example":value}), + Value::Number(value) if value.is_i64() || value.is_u64() => { + json!({"type":"integer","example":value}) + } + Value::Number(value) => json!({"type":"number","example":value}), + Value::String(value) => { + let example: String = value.chars().take(160).collect(); + json!({"type":"string","example":example}) + } + Value::Array(values) => { + let item = values + .first() + .map(|value| runtime_value_schema(value, collection, &format!("{path}/0"))) + .unwrap_or_else(|| json!({})); + json!({"type":"array","items":item}) + } + Value::Object(values) => { + let properties = values + .iter() + .map(|(name, value)| { + let child_path = format!("{path}/{}", json_pointer_name(name)); + ( + name.clone(), + runtime_value_schema(value, collection, &child_path), + ) + }) + .collect::>(); + json!({ + "type":"object", + "properties":properties, + "required":values.keys().collect::>(), + "additionalProperties":false, + }) + } + }; + if !path.is_empty() { + let object = schema.as_object_mut().expect("runtime schema"); + object.insert( + "readOnly".into(), + json!(!collection_is_mutable(collection) || path_changes_identity(collection, path)), + ); + let name = path.rsplit('/').next().unwrap_or(""); + let scalar_type = if value.is_number() { "f64" } else { "Value" }; + if let Some(unit) = field_unit(name, scalar_type) { + object.insert("x-unit".into(), json!(unit)); + } + } + schema +} + +fn editing_description(collection: Option<&str>) -> Value { + let mutable = collection.is_some_and(collection_is_mutable); + json!({ + "read_operation":"records", + "write_operation":mutable.then_some("set_properties"), + "selector":match collection { + Some("entities" | "objects") => "handle", + Some("header" | "summary_info") => "singleton", + Some(_) if mutable => "handle or name", + Some(_) => "read-only derived record", + None => "collection plus handle or name", + }, + "path":"RFC 6901 JSON Pointer relative to record.properties", + "sequence_path":"Replace {index} in item_path_template with a current zero-based array index", + "identity_paths":collection.map(identity_paths).unwrap_or(&[]), + "atomic":true, + "compare_and_set":"Add expected to an update to reject stale field values", + "validation":["document revision","request id","JSON type","enum variant","integer bounds","record identity","layer lock"], + }) +} + +fn collection_record_types(collection: &str) -> Vec { + let root = collection_root_type(collection).unwrap_or("Value"); + if matches!(collection, "entities" | "objects") { + return registry_types()[root]["variants"] + .as_array() + .into_iter() + .flatten() + .map(|variant| { + let fields = variant["fields"].as_array(); + let schema_type = fields + .filter(|fields| fields.len() == 1 && fields[0]["name"] == "0") + .and_then(|fields| fields[0]["type_id"].as_str()) + .unwrap_or(root); + json!({"type":variant["name"],"schema_type":schema_type}) + }) + .collect(); + } + vec![json!({ + "type":collection_record_name(collection).unwrap_or(root), + "schema_type":root, + })] +} + +fn requested_handle(request: &Value) -> Result { + request["handle"] + .as_str() + .and_then(|value| u64::from_str_radix(without_hex_prefix(value), 16).ok()) + .map(acadrust::Handle::new) + .ok_or_else(|| "set_properties requires a hexadecimal handle".to_string()) +} + +fn apply_updates( + properties: &mut Value, + request: &Value, + collection: &str, +) -> Result, String> { + let updates = request["updates"] + .as_array() + .ok_or_else(|| "set_properties requires a non-empty updates array".to_string())?; + if updates.is_empty() { + return Err("set_properties requires a non-empty updates array".into()); + } + let mut original_identity = Vec::new(); + collect_identity_values(properties, collection, "", &mut original_identity); + let mut paths = Vec::with_capacity(updates.len()); + for update in updates { + let path = update["path"] + .as_str() + .ok_or_else(|| "each update requires a JSON Pointer path".to_string())?; + if path.is_empty() || !path.starts_with('/') { + return Err("updates require a non-empty JSON Pointer path".into()); + } + if path_changes_identity(collection, path) { + return Err(format!("{path} is read-only identity data")); + } + let value = update + .get("value") + .ok_or_else(|| format!("update {path} requires value"))? + .clone(); + let target = properties + .pointer_mut(path) + .ok_or_else(|| format!("property does not exist: {path}"))?; + if let Some(expected) = update.get("expected") { + if target != expected { + return Err(format!("property changed before update: {path}")); + } + } + *target = value; + paths.push(path.to_string()); + } + let mut edited_identity = Vec::new(); + collect_identity_values(properties, collection, "", &mut edited_identity); + if edited_identity != original_identity { + return Err("record identity is read-only".into()); + } + Ok(paths) +} + +fn patched_table_entry( + table: &Table, + request: &Value, + collection: &str, +) -> Result<(T, T, Vec), String> +where + T: Clone + PartialEq + Serialize + DeserializeOwned + TableEntry, +{ + let requested = request["handle"].as_str().and_then(|value| { + u64::from_str_radix(without_hex_prefix(value), 16) + .ok() + .map(acadrust::Handle::new) + }); + let name = request["name"].as_str(); + let source = table + .iter() + .find(|entry| { + requested.is_some_and(|handle| entry.handle() == handle) + || name.is_some_and(|name| entry.name().eq_ignore_ascii_case(name)) + }) + .cloned() + .ok_or_else(|| format!("record does not exist in {collection}"))?; + let mut properties = json_value(&source)?; + let paths = apply_updates(&mut properties, request, collection)?; + let edited: T = serde_json::from_value(properties).map_err(|error| error.to_string())?; + if edited.handle() != source.handle() || edited.name() != source.name() { + return Err("record identity is read-only".into()); + } + Ok((source, edited, paths)) +} + +fn replace_table_entry(table: &mut Table, handle: acadrust::Handle, edited: T) { + let target = table + .iter_mut() + .find(|entry| entry.handle() == handle) + .expect("validated table entry"); + *target = edited; +} + +impl OpenCADStudio { + pub(super) fn record_capabilities(&self) -> Value { + let document = &self.tabs[self.active_tab].scene.document; + json!({ + "ok":true, + "api":"ocs-cad-automation", + "version":1, + "concurrency":{"document_id":true,"revision":true,"request_id":true}, + "transactions":{"undo":true,"redo":true,"atomic_property_updates":true,"batch":true}, + "geometry":{"query":true,"kernel_measurements":true,"spatial_filters":true,"interactive_commands":true}, + "records":{ + "read":"records", + "write":"set_properties", + "describe":"record_schema", + "path":"RFC 6901 JSON Pointer relative to properties", + "filters":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"], + "collections":COLLECTIONS.iter().map(|(name, mutable)|json!({ + "name":name, + "mutable":mutable, + "root_type":collection_root_type(name), + "count":collection_count(document, name), + })).collect::>() + }, + "editor":{"selection":true,"properties":true,"commands":true,"files":true,"capture":true,"events":true} + }) + } + + pub(super) fn record_schema(&self, request: &Value) -> Value { + let collection = request["collection"].as_str(); + if collection.is_some_and(|name| collection_root_type(name).is_none()) { + return failure( + "unknown_collection", + format!("unknown record collection: {}", collection.unwrap()), + ); + } + let collections = COLLECTIONS + .iter() + .map(|(name, mutable)| { + json!({ + "name":name, + "mutable":mutable, + "root_type":collection_root_type(name), + "identity_paths":identity_paths(name), + }) + }) + .collect::>(); + + let Some(requested_type) = request["type"].as_str() else { + let mut record_types = if let Some(collection) = collection { + collection_record_types(collection) + } else { + registry_types() + .iter() + .map(|(name, info)| json!({"type":name,"schema_type":name,"kind":info["kind"]})) + .collect() + }; + if let Some(search) = request["search"].as_str().filter(|value| !value.is_empty()) { + let search = search.to_ascii_lowercase(); + record_types.retain(|entry| { + entry["type"] + .as_str() + .is_some_and(|name| name.to_ascii_lowercase().contains(&search)) + }); + } + let count = record_types.len(); + let offset = request["offset"].as_u64().unwrap_or(0) as usize; + let limit = request["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; + let record_types = record_types + .into_iter() + .skip(offset) + .take(limit) + .collect::>(); + return json!({ + "ok":true, + "schema_version":1, + "collection":collection, + "root_type":collection.and_then(collection_root_type), + "collections":collections, + "count":count, + "returned":record_types.len(), + "next_offset":(offset + record_types.len() < count).then_some(offset + record_types.len()), + "record_types":record_types, + "editing":editing_description(collection), + "next":"Call record_schema again with type set to a record type or schema_type returned here." + }); + }; + + let root_type = collection + .and_then(|collection| { + collection_record_types(collection) + .into_iter() + .find(|entry| { + ["type", "schema_type"].iter().any(|key| { + entry[*key] + .as_str() + .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)) + }) + }) + .and_then(|entry| entry["schema_type"].as_str().map(str::to_string)) + }) + .or_else(|| resolved_type_name(requested_type)) + .or_else(|| { + (requested_type == "Value" + || collection.and_then(collection_root_type) == Some("Value")) + .then(|| "Value".to_string()) + }); + let Some(root_type) = root_type else { + return failure( + "unknown_record_type", + format!("unknown record type: {requested_type}"), + ); + }; + let mut fields = Vec::new(); + let direct_collection_variant = + collection + .and_then(collection_root_type) + .is_some_and(|collection_root| { + collection_root == root_type && !requested_type.eq_ignore_ascii_case(&root_type) + }) + && registry_types() + .get(&root_type) + .is_some_and(|info| info["kind"] == "Enum"); + if direct_collection_variant { + append_variant_field_descriptions(&root_type, requested_type, collection, &mut fields); + } else { + append_field_descriptions(&root_type, "", collection, 0, &mut Vec::new(), &mut fields); + } + let definitions = referenced_definitions(&root_type); + let runtime_schema = collection.and_then(|collection| { + collection_records(&self.tabs[self.active_tab].scene.document, collection) + .ok()? + .into_iter() + .find(|record| { + let type_matches = record["type"] + .as_str() + .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)) + || collection_root_type(collection) + .is_some_and(|name| name.eq_ignore_ascii_case(requested_type)); + type_matches + && request["handle"].as_str().is_none_or(|handle| { + record["handle"].as_str().is_some_and(|actual| { + actual.eq_ignore_ascii_case(without_hex_prefix(handle)) + }) + }) + && request["name"].as_str().is_none_or(|name| { + record["name"] + .as_str() + .is_some_and(|actual| actual.eq_ignore_ascii_case(name)) + }) + }) + .map(|record| runtime_value_schema(&record["properties"], collection, "")) + }); + json!({ + "ok":true, + "schema_version":1, + "collection":collection, + "record_type":requested_type, + "schema_type":root_type, + "schema":registry_types().get(&root_type), + "definitions":definitions, + "fields":fields, + "runtime_schema":runtime_schema, + "editing":editing_description(collection), + "notes":[ + "Enum constraints list every accepted serialized variant.", + "Integer constraints reflect the complete serialized range.", + "Unit annotations are supplied only where the serialized field name and type are unambiguous.", + "runtime_schema describes an existing matching record when one is available." + ] + }) + } + + pub(super) fn record_query(&self, request: &Value) -> Value { + let document = &self.tabs[self.active_tab].scene.document; + let Some(collection) = request["collection"].as_str() else { + return self.record_capabilities(); + }; + let mut records = Vec::new(); + if collection == "all" { + for (name, _) in COLLECTIONS { + match collection_records(document, name) { + Ok(mut values) => records.append(&mut values), + Err(error) => return failure("serialization_failed", error), + } + } + } else { + match collection_records(document, collection) { + Ok(values) => records = values, + Err(error) => return failure("unknown_collection", error), + } + } + let mut matched = Vec::new(); + for record in records { + match matches_filters(&record, request) { + Ok(true) => matched.push(record), + Ok(false) => {} + Err(error) => return failure("invalid_filter", error), + } + } + let count = matched.len(); + let offset = request["offset"].as_u64().unwrap_or(0) as usize; + let limit = request["limit"].as_u64().unwrap_or(1000).min(10_000) as usize; + let mut returned = Vec::new(); + for record in matched.into_iter().skip(offset).take(limit) { + match project_paths(record, request["paths"].as_array()) { + Ok(record) => returned.push(record), + Err(error) => return failure("invalid_projection", error), + } + } + json!({ + "ok":true, + "document_id":self.tabs[self.active_tab].id, + "revision":self.tabs[self.active_tab].edit_revision, + "geometry_revision":self.tabs[self.active_tab].scene.geometry_epoch, + "collection":collection, + "count":count, + "returned":returned.len(), + "next_offset":(offset + returned.len() < count).then_some(offset + returned.len()), + "records":returned, + }) + } + + pub(super) fn control_set_record_properties( + &mut self, + request: &Value, + ) -> Result, Value> { + let collection = request["collection"] + .as_str() + .ok_or_else(|| failure("collection_required", "set_properties requires collection"))?; + if !COLLECTIONS + .iter() + .any(|(name, mutable)| *name == collection && *mutable) + { + return Err(failure( + "read_only_collection", + format!("collection is absent or read-only: {collection}"), + )); + } + let i = self.active_tab; + let changed; + let mut result_handle = None; + let mut result_name = None; + let paths; + match collection { + "entities" => { + let handle = + requested_handle(request).map_err(|error| failure("invalid_handle", error))?; + if self.tabs[i].scene.is_layer_locked(handle) { + return Err(failure("layer_locked", "entity is on a locked layer")); + } + let source = self.tabs[i] + .scene + .document + .get_entity(handle) + .cloned() + .ok_or_else(|| failure("record_absent", "entity does not exist"))?; + let (kind, mut properties) = + enum_parts(&source).map_err(|error| failure("serialization_failed", error))?; + paths = apply_updates(&mut properties, request, collection) + .map_err(|error| failure("invalid_update", error))?; + let mut edited: acadrust::EntityType = decode_enum(&kind, properties) + .map_err(|error| failure("invalid_value", error))?; + if edited.common().handle != handle { + return Err(failure("identity_changed", "entity identity is read-only")); + } + edited.preserve_storage_data_from(&source); + changed = edited != source; + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + if !self.tabs[i].scene.update_entity(edited) { + self.discard_last_undo_entry(i); + return Err(failure("update_failed", "entity could not be updated")); + } + self.tabs[i].dirty = true; + } + result_handle = Some(handle_text(handle)); + } + "objects" => { + let handle = + requested_handle(request).map_err(|error| failure("invalid_handle", error))?; + let source = self.tabs[i] + .scene + .document + .objects + .get(&handle) + .cloned() + .ok_or_else(|| failure("record_absent", "object does not exist"))?; + let (kind, mut properties) = + enum_parts(&source).map_err(|error| failure("serialization_failed", error))?; + paths = apply_updates(&mut properties, request, collection) + .map_err(|error| failure("invalid_update", error))?; + let mut edited: acadrust::objects::ObjectType = decode_enum(&kind, properties) + .map_err(|error| failure("invalid_value", error))?; + edited.preserve_storage_data_from(&source); + changed = edited != source; + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + self.tabs[i].scene.document.objects.insert(handle, edited); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + result_handle = Some(handle_text(handle)); + } + "header" => { + let source = self.tabs[i].scene.document.header.clone(); + let mut properties = + json_value(&source).map_err(|error| failure("serialization_failed", error))?; + paths = apply_updates(&mut properties, request, collection) + .map_err(|error| failure("invalid_update", error))?; + let edited = + serde_json::from_value(properties).map_err(|error: serde_json::Error| { + failure("invalid_value", error.to_string()) + })?; + changed = edited != source; + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + self.tabs[i].scene.document.header = edited; + self.tabs[i].adopt_active_ucs_from_header(); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + result_name = Some("header".to_string()); + } + "summary_info" => { + let source = self.tabs[i].scene.document.summary_info.clone(); + let mut properties = + json_value(&source).map_err(|error| failure("serialization_failed", error))?; + paths = apply_updates(&mut properties, request, collection) + .map_err(|error| failure("invalid_update", error))?; + let edited = + serde_json::from_value(properties).map_err(|error: serde_json::Error| { + failure("invalid_value", error.to_string()) + })?; + changed = edited != source; + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + self.tabs[i].scene.document.summary_info = edited; + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + result_name = Some("summary_info".to_string()); + } + _ => { + macro_rules! patch_table { + ($field:ident) => {{ + let (source, edited, changed_paths) = patched_table_entry( + &self.tabs[i].scene.document.$field, + request, + collection, + ) + .map_err(|error| failure("invalid_update", error))?; + paths = changed_paths; + changed = edited != source; + let handle = source.handle(); + result_handle = Some(handle_text(handle)); + result_name = Some(source.name().to_string()); + if changed { + self.push_undo_snapshot(i, "MCP SET_PROPERTIES"); + replace_table_entry( + &mut self.tabs[i].scene.document.$field, + handle, + edited, + ); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + } + }}; + } + match collection { + "layers" => patch_table!(layers), + "line_types" => patch_table!(line_types), + "text_styles" => patch_table!(text_styles), + "block_records" => patch_table!(block_records), + "dim_styles" => patch_table!(dim_styles), + "app_ids" => patch_table!(app_ids), + "views" => patch_table!(views), + "vports" => patch_table!(vports), + "ucss" => patch_table!(ucss), + "vx_table" => patch_table!(vx_table), + _ => unreachable!(), + } + } + } + if changed { + self.refresh_properties(); + } + self.set_control_result(json!({ + "collection":collection, + "handle":result_handle, + "name":result_name, + "paths":paths, + "changed":changed, + })); + Ok(Task::none()) + } +} + +#[cfg(test)] +mod tests { + use acadrust::entities::{AttributeEntity, EntityType, Insert}; + use acadrust::objects::{Dictionary, ObjectType}; + use acadrust::types::{Handle, Vector3}; + use serde_json::{Value, json}; + + use super::OpenCADStudio; + + fn execute(app: &mut OpenCADStudio, mut request: Value, id: &str) -> Value { + let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#); + let object = request.as_object_mut().unwrap(); + object.insert("protocol".into(), json!(1)); + object.insert("request_id".into(), json!(id)); + object.insert("document_id".into(), state["document_id"].clone()); + object.insert("revision".into(), state["revision"].clone()); + app.automation_op(&request.to_string()) + } + + #[test] + fn records_query_and_edit_every_mutable_record_family() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + let mut insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0)); + insert + .attributes + .push(AttributeEntity::simple("COMPANY", "BPH")); + let entity = app.tabs[i].scene.add_entity(EntityType::Insert(insert)); + + let object_handle = Handle::new(0x500); + let mut dictionary = Dictionary::new(); + dictionary.handle = object_handle; + app.tabs[i] + .scene + .document + .objects + .insert(object_handle, ObjectType::Dictionary(dictionary)); + + let records = app.record_query(&json!({ + "collection":"entities", + "type":"Insert", + "where":[{"path":"/attributes/0/tag","value":"COMPANY"}] + })); + assert_eq!(records["count"], 1); + assert_eq!(records["records"][0]["properties"]["block_name"], "A_CPT"); + assert_eq!( + records["records"][0]["properties"]["attributes"][0]["value"], + "BPH" + ); + + let edited = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"entities", + "handle":format!("{:X}", entity.value()), + "updates":[ + {"path":"/attributes/0/value","expected":"BPH","value":"Edited"}, + {"path":"/insert_point/x","value":20.0} + ] + }), + "edit-entity", + ); + assert_eq!(edited["status"], "completed", "{edited}"); + assert_eq!(edited["result"]["changed"], true); + let EntityType::Insert(insert) = app.tabs[i].scene.document.get_entity(entity).unwrap() + else { + panic!("expected insert") + }; + assert_eq!(insert.attributes[0].value, "Edited"); + assert_eq!(insert.insert_point.x, 20.0); + + let object_edit = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"objects", + "handle":"500", + "updates":[{"path":"/hard_owner","value":true}] + }), + "edit-object", + ); + assert_eq!(object_edit["result"]["changed"], true, "{object_edit}"); + let Some(ObjectType::Dictionary(dictionary)) = + app.tabs[i].scene.document.objects.get(&object_handle) + else { + panic!("expected dictionary") + }; + assert!(dictionary.hard_owner); + + let layer_edit = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"layers", + "name":"0", + "updates":[{"path":"/is_plottable","value":false}] + }), + "edit-layer", + ); + assert_eq!(layer_edit["result"]["changed"], true, "{layer_edit}"); + assert!( + !app.tabs[i] + .scene + .document + .layers + .get("0") + .unwrap() + .is_plottable + ); + + let header_edit = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"header", + "updates":[{"path":"/point_display_size","value":7.5}] + }), + "edit-header", + ); + assert_eq!(header_edit["result"]["changed"], true, "{header_edit}"); + assert_eq!(app.tabs[i].scene.document.header.point_display_size, 7.5); + + let summary_edit = execute( + &mut app, + json!({ + "op":"set_properties", + "collection":"summary_info", + "updates":[{"path":"/author","value":"MCP"}] + }), + "edit-summary", + ); + assert_eq!(summary_edit["result"]["changed"], true, "{summary_edit}"); + assert_eq!(app.tabs[i].scene.document.summary_info.author, "MCP"); + + let undo = execute(&mut app, json!({"op":"undo"}), "undo-summary"); + assert_eq!(undo["status"], "completed", "{undo}"); + assert!(app.tabs[i].scene.document.summary_info.author.is_empty()); + } + + #[test] + fn record_schema_describes_absent_types_fields_enums_units_and_write_rules() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + + let catalog = app.record_schema(&json!({"collection":"entities"})); + assert_eq!(catalog["ok"], true, "{catalog}"); + assert_eq!(catalog["root_type"], "EntityType"); + assert_eq!(catalog["count"], 48); + assert!(catalog["record_types"] + .as_array() + .unwrap() + .iter() + .any(|record| record["type"] == "Insert" && record["schema_type"] == "Insert")); + + let schema = app.record_schema(&json!({ + "collection":"entities", + "type":"Insert" + })); + assert_eq!(schema["ok"], true, "{schema}"); + assert!(schema["definitions"].get("Insert").is_some()); + assert!(schema["definitions"].get("AttributeEntity").is_some()); + assert!(schema["runtime_schema"].is_null()); + let fields = schema["fields"].as_array().unwrap(); + let field = |path: &str| { + fields + .iter() + .find(|field| field["path"] == path) + .unwrap_or_else(|| panic!("missing schema field {path}")) + }; + assert_eq!(field("/common/handle")["writable"], false); + assert_eq!(field("/insert_point")["unit"], "drawing_units"); + assert_eq!(field("/rotation")["unit"], "radians"); + assert_eq!( + field("/attributes")["item_path_template"], + "/attributes/{index}" + ); + assert_eq!(field("/attributes/{index}/value")["writable"], true); + + let text = app.record_schema(&json!({"collection":"entities","type":"Text"})); + let alignment = text["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["path"] == "/horizontal_alignment") + .expect("horizontal alignment schema"); + assert!(alignment["constraints"]["allowed_variants"] + .as_array() + .is_some_and(|variants| variants.len() >= 3)); + + let unknown = app.record_schema(&json!({"collection":"objects","type":"Unknown"})); + assert_eq!(unknown["schema_type"], "ObjectType"); + let fields = unknown["fields"].as_array().unwrap(); + let field = |path: &str| { + fields + .iter() + .find(|field| field["path"] == path) + .unwrap_or_else(|| panic!("missing enum variant field {path}")) + }; + assert_eq!(field("/type_name")["writable"], true); + assert_eq!(field("/handle")["writable"], false); + assert_eq!(field("/owner")["writable"], false); + assert_eq!(field("/raw_dwg_handle_bits")["writable"], true); + + let dimension = app.record_schema(&json!({"collection":"entities","type":"Dimension"})); + let fields = dimension["fields"].as_array().unwrap(); + assert!(fields.iter().any(|field| field["path"] == "/Linear")); + assert!(fields.iter().any( + |field| field["path"] == "/Linear/base/common/handle" && field["writable"] == false + )); + assert!(fields.iter().any(|field| field["path"] == "/Radius")); + + let section_style = app + .record_schema(&json!({"collection":"section_view_style","type":"SectionViewStyle"})); + assert_eq!(section_style["schema_type"], "EntitySectionViewStyle"); + assert!(section_style["fields"] + .as_array() + .is_some_and(|fields| fields + .iter() + .any(|field| field["path"] == "/arrow_size" && field["unit"] == "drawing_units"))); + + let class = app.record_schema(&json!({"collection":"classes","type":"Class"})); + assert_eq!(class["schema_type"], "DxfClass"); + assert!(class["fields"] + .as_array() + .is_some_and(|fields| fields.iter().any(|field| field["path"] == "/dxf_name"))); + + for (collection, root) in [("entities", "EntityType"), ("objects", "ObjectType")] { + let complete = app.record_schema(&json!({"collection":collection,"type":root})); + assert_eq!(complete["ok"], true, "{root}: {complete}"); + assert!(complete["fields"] + .as_array() + .is_some_and(|fields| !fields.is_empty())); + let bytes = serde_json::to_vec(&complete).unwrap().len(); + assert!(bytes < 8 * 1024 * 1024, "{root} schema is {bytes} bytes"); + } + } + + #[test] + fn record_updates_reject_type_identity_and_compare_failures() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let entity = app.tabs[i] + .scene + .add_entity(EntityType::Insert(Insert::new("A", Vector3::ZERO))); + let handle = format!("{:X}", entity.value()); + + let wrong_type = execute( + &mut app, + json!({"op":"set_properties","collection":"entities","handle":handle, + "updates":[{"path":"/rotation","value":"invalid"}]}), + "wrong-type", + ); + assert_eq!(wrong_type["code"], "invalid_value", "{wrong_type}"); + + let identity = execute( + &mut app, + json!({"op":"set_properties","collection":"entities","handle":handle, + "updates":[{"path":"/common/handle","value":99}]}), + "identity", + ); + assert_eq!(identity["code"], "invalid_update", "{identity}"); + + let identity_parent = execute( + &mut app, + json!({"op":"set_properties","collection":"entities","handle":handle, + "updates":[{"path":"/common","value":{}}]}), + "identity-parent", + ); + assert_eq!( + identity_parent["code"], "invalid_update", + "{identity_parent}" + ); + + let compare = execute( + &mut app, + json!({"op":"set_properties","collection":"entities","handle":handle, + "updates":[{"path":"/rotation","expected":1.0,"value":2.0}]}), + "compare", + ); + assert_eq!(compare["code"], "invalid_update", "{compare}"); + } +} diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index feaf6b016..1e1888055 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -740,6 +740,14 @@ impl OpenCADStudio { .as_mut() .map(|c| c.on_preview_wires(cur)) .unwrap_or_default(); + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); } } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index fb3f6342c..9b2dd763b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -133,6 +133,9 @@ fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f "A1" => Some(PaperSize::A1), "A0" => Some(PaperSize::A0), "A4" => Some(PaperSize::A4), + "Letter" => Some(PaperSize::Letter), + "Legal" => Some(PaperSize::Legal), + "Tabloid" => Some(PaperSize::Tabloid), _ => None, }; if let Some(paper) = standard { @@ -4297,7 +4300,10 @@ pub(super) fn on_open_file(&mut self) -> Task { .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(&style_name)); let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height); - if !matches!(paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0") + if !matches!( + paper.as_str(), + "A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid" + ) && !ps.paper_size.is_empty() { paper = ps.paper_size.clone(); @@ -4401,6 +4407,9 @@ pub(super) fn on_open_file(&mut self) -> Task { "A2" => PaperSize::A2, "A1" => PaperSize::A1, "A0" => PaperSize::A0, + "Letter" => PaperSize::Letter, + "Legal" => PaperSize::Legal, + "Tabloid" => PaperSize::Tabloid, _ => PaperSize::A4, }; let orient = if d.orientation == "Portrait" { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a7dd2ca1c..b076c18a0 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -265,6 +265,11 @@ impl OpenCADStudio { self.control_observe_user_message(&msg); let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); + // Keep the stable tab id because dispatch may switch or close tabs. + let perf_edit_before = perf_started.map(|started| { + let tab = &self.tabs[self.active_tab]; + (started, tab.id, tab.scene.geometry_epoch) + }); // A modal dialog must capture the keyboard the same way it already // captures the mouse. Otherwise keystrokes from the global key // subscription leak past the modal into the command line and fire as @@ -324,6 +329,17 @@ impl OpenCADStudio { self.otrack_active = None; self.otrack_kind = None; } + if let Some((started, tab_id, before)) = perf_edit_before { + if let Some(tab) = self.tabs.iter().find(|tab| tab.id == tab_id) { + if tab.scene.geometry_epoch != before { + tab.scene.record_nav_perf_caused( + crate::scene::NavPerfOp::Edit, + perf_label, + started, + ); + } + } + } if let Some(started) = perf_started { let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0; if elapsed_ms >= 5.0 { @@ -1953,6 +1969,14 @@ impl OpenCADStudio { self.dispatch_command(&cmd) } + Message::ScriptLine(line) => { + if line.trim().is_empty() { + self.feed_command(crate::command::StepInput::Enter) + } else { + self.run_command_line(&line) + } + } + Message::ToggleLayers => { if self.active_modal == Some(super::ModalKind::Layers) { self.ribbon.deactivate_tool_if("LAYERS"); diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index d7d9e812d..8bfafa85d 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2499,6 +2499,14 @@ impl OpenCADStudio { previews.push(guide); } } + let preview_hidden = self.tabs[i] + .active_cmd + .as_ref() + .map(|command| command.preview_hidden_handles().to_vec()) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_command_preview_hidden(&preview_hidden); self.tabs[i].scene.set_preview_wires(previews); } else { self.tabs[i].snap_result = None; diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index fc37fd3f8..2c82dce24 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2393,15 +2393,6 @@ impl OpenCADStudio { let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal); #[cfg(target_arch = "wasm32")] let single_instance = Subscription::none(); - // A second file opened via Finder while this process is already - // running (#1039) — delivered as a warm Apple Event straight to this - // process, bypassing `src/bin/ocs_launcher.rs` entirely (that only - // runs for the initial cold launch). See `io::macos_open_events`. - #[cfg(target_os = "macos")] - let macos_open_events = - crate::io::macos_open_events::subscribe().map(Message::OpenExternal); - #[cfg(not(target_os = "macos"))] - let macos_open_events = Subscription::none(); // Drain plugin-to-host requests on a timer when any plugin is loaded. // This lets long-lived plugin sessions (e.g. the Python REPL) mutate the // host document without requiring a user-generated message. @@ -2577,7 +2568,6 @@ impl OpenCADStudio { autosave, plugin_drain, single_instance, - macos_open_events, hatch_pattern_keys, keyboard_events, ]) diff --git a/src/bin/ocs_launcher.rs b/src/bin/ocs_launcher.rs index ed8e461b4..7022f2602 100644 --- a/src/bin/ocs_launcher.rs +++ b/src/bin/ocs_launcher.rs @@ -1,77 +1,33 @@ -//! macOS-only launcher shim, packaged as the .app's `CFBundleExecutable` -//! (`packaging/build_macos_signed.sh`) instead of the real GUI binary -//! (#1039 — "Cannot OPEN WITH in FINDER MAC OS"). +//! Receives macOS document-open callbacks before the GUI runtime starts. //! -//! ## Why this exists -//! -//! Finder never delivers an opened file as a command-line argument — a -//! double-click, "Open With", and even a plain `open -a OpenCADStudio -//! file.dwg` (no `--args`) all resolve it, then hand the running/launching -//! process the path as a `kAEOpenDocuments` Apple Event instead. The real -//! binary only ever reads `std::env::args()` (see `src/cli.rs`), so none of -//! those reached it — Finder just showed "cannot open files in this format". -//! -//! The standard, documented fix is an `NSApplicationDelegate` implementing -//! `application:openURLs:`. That is not available inside the real binary: -//! winit already installs its own delegate to own window/lifecycle events -//! (`platform_impl::macos::app_state::ApplicationDelegate`), `NSApplication` -//! has exactly one delegate slot, and winit's class doesn't implement the -//! open-document method. Several other approaches were tried and measured, -//! not assumed, against real Finder-equivalent opens: -//! -//! * Registering a raw `AEInstallEventHandler` callback (bypassing the -//! delegate protocol) from inside the real binary, at every point -//! tried — before `app::run()`, on `iced::Subscription`'s first poll, -//! even as the literal first statement of `main()` — reliably caught -//! every Apple Event sent to an *already-running, cleanly-launched* -//! process, but never a cold-launch event. `io::macos_open_events` still -//! carries this: it handles a *second* file opened while this process -//! is already running. -//! * A raw `AEInstallEventHandler` in a separate, minimal, non-AppKit -//! process (no winit/iced, just a manual `CFRunLoopRunInMode` pump) -//! still missed the cold-launch event, ruling out "the real binary's -//! heavy dependency graph loading before `main()` wins the race". -//! * A from-scratch `NSApplication` with our own delegate (this file) does -//! catch the cold-launch event reliably. But relaying by replacing this -//! process's image (`exec`, same PID) turned out to have its own -//! failure mode: Launch Services tracks "the running instance of this -//! bundle" by the Application Serial Number it handed out at launch — -//! tied to *this* process, not to the real binary. `exec` doesn't change -//! that ASN, but a later document open sent to it after the `exec` -//! silently never arrived anywhere (measured via a real second "Open -//! With", not assumed) — and a plain `spawn`'d child in place of `exec` -//! didn't help either: `lsappinfo` showed Launch Services still -//! addressing *this* launcher's ASN for the next open, now pointed at a -//! process that had already exited. -//! -//! So this process has to stay alive for the app's session — it is, as far -//! as Launch Services is concerned, "the app". Every open it receives (cold -//! or warm) is relayed to the real GUI binary over the exact channel a -//! second CLI invocation already uses (`io::single_instance`): hand off to -//! an already-running editor if one answers, otherwise launch a fresh one -//! with the files on argv. `NSApplicationActivationPolicyAccessory` keeps it -//! out of the Dock and Cmd-Tab — it owns no window and has nothing to show. -//! -//! Kept intentionally free of every other dependency in this crate (iced, -//! wgpu, cadkernel, …) beyond what relaying needs, so its own startup is as -//! close to instant as possible — plausibly part of why the isolated-process -//! approach above catches the cold-launch event when the full binary didn't. +//! The app bundle uses this helper as its `CFBundleExecutable`. It forwards +//! Finder URLs to a running editor or starts the sibling GUI binary, then +//! stays alive so later opens keep reaching the same AppKit delegate. +#[cfg(target_os = "macos")] use std::path::PathBuf; +#[cfg(target_os = "macos")] use std::sync::atomic::{AtomicBool, Ordering}; +#[cfg(target_os = "macos")] use objc2::rc::Retained; +#[cfg(target_os = "macos")] use objc2::runtime::ProtocolObject; +#[cfg(target_os = "macos")] use objc2::{declare_class, msg_send_id, mutability, ClassType, DeclaredClass}; +#[cfg(target_os = "macos")] use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate}; +#[cfg(target_os = "macos")] use objc2_foundation::{ MainThreadMarker, NSArray, NSNotification, NSObject, NSObjectProtocol, NSURL, }; +#[cfg(target_os = "macos")] use OpenCADStudio::io::single_instance; /// Name of the real GUI binary inside `Contents/MacOS/`, sibling to this /// launcher. Must match the packaging script's bundle assembly step. +#[cfg(target_os = "macos")] const REAL_BINARY_NAME: &str = "OpenCADStudio-App"; /// How long to wait, after `applicationDidFinishLaunching:`, for an @@ -80,13 +36,20 @@ const REAL_BINARY_NAME: &str = "OpenCADStudio-App"; /// file). `application:openURLs:` arrives as part of the same startup /// sequence when it's coming at all — essentially immediately, not after a /// meaningful delay — so this window is slack, not a user-visible wait. +#[cfg(target_os = "macos")] const NO_DOCUMENTS_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(400); /// Set once `application:openURLs:` has handled a launch's documents, so the /// delayed "no documents" fallback in `applicationDidFinishLaunching:` knows /// to stay out of the way instead of *also* launching a bare instance. +#[cfg(target_os = "macos")] static DOCS_HANDLED: AtomicBool = AtomicBool::new(false); +/// The first spawned GUI owns the launcher lifetime. +#[cfg(target_os = "macos")] +static GUI_STARTED: AtomicBool = AtomicBool::new(false); + +#[cfg(target_os = "macos")] fn real_binary_path() -> PathBuf { let exe = std::env::current_exe().expect("could not resolve the launcher's own path"); exe.parent() @@ -94,37 +57,36 @@ fn real_binary_path() -> PathBuf { .join(REAL_BINARY_NAME) } -/// Deliver `files` to a running editor if one answers on the single-instance -/// port, otherwise launch a fresh one with `files` on argv — the same -/// channel a second `OpenCADStudio file.dwg` from a terminal already uses. -/// This process (the launcher) is never itself a candidate to serve that -/// port: `try_connect_existing` only ever connects, never binds, so it can't -/// steal the port out from under the real binary that's either already -/// running or about to claim it on its own. +/// Forward to a running editor or start the bundled GUI. +#[cfg(target_os = "macos")] fn deliver_or_launch(files: &[String]) { let paths: Vec = files.iter().map(PathBuf::from).collect(); - let delivered = single_instance::try_connect_existing() - .map(|stream| single_instance::handoff(stream, &paths)) - .unwrap_or(false); - if !delivered { - if let Err(err) = std::process::Command::new(real_binary_path()).args(files).spawn() { - eprintln!("OpenCADStudio launcher: failed to launch the real binary: {err}"); + if let Some(stream) = single_instance::try_connect_existing() { + if single_instance::handoff(stream, &paths) { + reassert_accessory_policy(); + return; + } + } + match std::process::Command::new(real_binary_path()) + .args(files) + .spawn() + { + Ok(mut child) if !GUI_STARTED.swap(true, Ordering::SeqCst) => { + std::thread::spawn(move || { + let _ = child.wait(); + std::process::exit(0); + }); + } + Ok(_) => {} + Err(err) => { + eprintln!("OpenCADStudio launcher: failed to launch the GUI: {err}"); } } reassert_accessory_policy(); } -/// AppKit activates this process — promoting it from -/// NSApplicationActivationPolicyAccessory (no Dock icon, set at startup) to -/// a normal foreground app with its own Dock icon — as a side effect of -/// handling an open-document Apple Event, even though nothing here ever -/// shows a window. Measured via `lsappinfo`, not assumed: the launcher's own -/// entry flips from type="UIElement" to type="Foreground" the moment it -/// handles a *second* Apple Event (a fresh cold launch's own first event -/// doesn't trigger this — the policy is presumably still settling at that -/// point). Set it back immediately after relaying, so a warm "Open With" -/// doesn't leave a second, contentless Dock icon sitting next to the real -/// editor's. +/// Keep the windowless relay out of the Dock after an open event activates it. +#[cfg(target_os = "macos")] fn reassert_accessory_policy() { if let Some(mtm) = MainThreadMarker::new() { NSApplication::sharedApplication(mtm) @@ -132,6 +94,7 @@ fn reassert_accessory_policy() { } } +#[cfg(target_os = "macos")] declare_class!( struct Delegate; @@ -167,15 +130,7 @@ declare_class!( deliver_or_launch(&files); } - /// Fires when this already-running process is reactivated with no - /// visible windows to show — clicking the Dock icon, or Finder - /// launching the bundle again while Launch Services still considers - /// it running (this launcher's own ASN never dies between opens; see - /// the module doc). Without this, quitting the real GUI window while - /// this relay stays alive left the app in a state where reopening it - /// did nothing: no fresh `applicationDidFinishLaunching:` (that only - /// fires once, at this process's own cold start) and no - /// `application:openURLs:` (no document was named). + /// Start or activate the GUI when the bundle is reopened without files. #[method(applicationShouldHandleReopen:hasVisibleWindows:)] fn should_handle_reopen(&self, _sender: &NSApplication, has_visible_windows: bool) -> bool { if !has_visible_windows { @@ -186,6 +141,7 @@ declare_class!( } ); +#[cfg(target_os = "macos")] impl Delegate { fn new(mtm: MainThreadMarker) -> Retained { let this = mtm.alloc(); @@ -193,6 +149,7 @@ impl Delegate { } } +#[cfg(target_os = "macos")] fn main() { let mtm = MainThreadMarker::new().expect("the launcher must run on the main thread"); let app = NSApplication::sharedApplication(mtm); @@ -203,9 +160,9 @@ fn main() { let proto: &ProtocolObject = ProtocolObject::from_ref(&*delegate); app.setDelegate(Some(proto)); - // Stays running for the app's session — see the module doc for why: - // Launch Services keeps addressing future document-opens to this - // process's Application Serial Number, not to whatever it spawns. // SAFETY: called once, on the main thread, immediately after `setDelegate`. unsafe { app.run() }; } + +#[cfg(not(target_os = "macos"))] +fn main() {} diff --git a/src/command.rs b/src/command.rs index a925be496..771a0b58a 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1195,6 +1195,15 @@ pub struct LoftOptions { pub align_direction: bool, } +/// One face-selection edit made while collecting a solid shell operation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ShellFaceAction { + Remove(DVec3), + Add(DVec3), + RemoveAll, + AddAll, +} + impl Default for LoftOptions { fn default() -> Self { Self { @@ -1403,6 +1412,8 @@ pub enum CmdResult { Measurement(String), /// Print a measurement result and keep the command active. ReportMeasurement(String), + /// Print an input error and keep the command active. + ReportError(String), /// Print a measurement result, clear the current selection, and keep the command active. ReportMeasurementAndDeselect(String), /// Clear the current selection and keep the command active at its updated step. @@ -1576,16 +1587,39 @@ pub enum CmdResult { options: LoftOptions, color: [f32; 4], }, - /// Round or bevel the straight edge nearest `pick` on a solid. + /// Round or bevel one or more resolved B-rep edges on a solid. SolidEdgeBlend { handle: Handle, - pick: DVec3, + edges: Vec, + base_face: Option, value: f64, + other_value: f64, fillet: bool, }, + /// Offset a solid and optionally open selected faces. + SolidShell { + handle: Handle, + actions: Vec, + distance: f64, + }, SolidSubtract { bases: Vec, cutters: Vec, + convert_meshes: bool, + }, + /// Split every selected solid or surface with one arbitrary plane. + /// `keep_point == None` retains both sides; otherwise it selects the side + /// containing that WCS point. + SliceEntities { + targets: Vec, + plane: cadkernel::space::Plane, + keep_point: Option, + }, + /// Split selected solids or surfaces with one selected analytic sheet. + SliceSurfaceEntities { + targets: Vec, + cutter: Box, + keep_point: Option, }, /// INSERT landed on a block that has AttributeDefinitions. /// The host should look up the attdefs for `block_name` from the document diff --git a/src/entities/circle.rs b/src/entities/circle.rs index 34d579453..bc037abfb 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -10,7 +10,39 @@ use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, R use crate::scene::model::object::{GripApply, GripDef, PropSection}; use crate::scene::model::wire_model::TangentGeom; -fn to_render(circle: &Circle) -> RenderEntity { +/// The number of segments to tessellate a circle into, based on its radius and the +/// current viewport's world units per pixel (`wpp`). +/// +/// Targets a maximum chord sagitta of ~0.25 pixels on screen so circles stay visually +/// round at any zoom level, while bounded between a minimum of 48 (preventing +/// coarse polygons when small or far away) and a maximum of 4096 (capping buffer size +/// on extreme zoom). +pub fn circle_segments(radius: f64, wpp: Option) -> usize { + const MIN_SEGMENTS: usize = 48; + const MAX_SEGMENTS: usize = 4096; + let Some(w) = wpp.filter(|&w| w.is_finite() && w > 0.0) else { + return MIN_SEGMENTS; + }; + let r = radius.abs(); + if !r.is_finite() || r <= 0.0 { + return MIN_SEGMENTS; + } + // Target chord sagitta of ~0.25 px on screen: + // sag = r * (1 - cos(theta / 2)) <= tolerance + let tolerance = (w as f64) * 0.25; + if r <= tolerance { + return MIN_SEGMENTS; + } + let cos_half = (1.0 - tolerance / r).clamp(-1.0, 1.0); + let step = 2.0 * cos_half.acos(); + if step <= 0.0 { + return MAX_SEGMENTS; + } + let n = (std::f64::consts::TAU / step).ceil() as usize; + n.clamp(MIN_SEGMENTS, MAX_SEGMENTS) +} + +pub fn to_render_with_wpp(circle: &Circle, wpp: Option) -> RenderEntity { let cx = circle.center.x; let cy = circle.center.y; let cz = circle.center.z; @@ -31,10 +63,12 @@ fn to_render(circle: &Circle) -> RenderEntity { radius: r, }; + let n = circle_segments(r, wpp); + if circle.thickness.abs() > 1e-10 { let t = circle.thickness; let (nx, ny, nz) = normal; - let n = 64usize; + let n = n.max(64); let tau = std::f64::consts::TAU; let circ_pt = |a: f64| -> (f64, f64, f64) { let (c, s) = (a.cos(), a.sin()); @@ -76,7 +110,8 @@ fn to_render(circle: &Circle) -> RenderEntity { } // Keep tessellation on the entity curve for large-coordinate precision. - let pts = crate::entities::curve::curve_points(&curve); + let max_angle = std::f64::consts::TAU / (n as f64); + let pts = curve.tessellate_angle(max_angle); RenderEntity { pick_tris: Vec::new(), @@ -88,6 +123,22 @@ fn to_render(circle: &Circle) -> RenderEntity { } } +fn to_render(circle: &Circle) -> RenderEntity { + to_render_with_wpp(circle, None) +} + +/// Zoom-aware render converter for EntityType::Circle. +pub fn relative_render( + entity: &acadrust::EntityType, + _document: &acadrust::CadDocument, + wpp: Option, +) -> Option { + let acadrust::EntityType::Circle(circle) = entity else { + return None; + }; + Some(to_render_with_wpp(circle, wpp)) +} + fn grips(circle: &Circle) -> Vec { let center = circle.center_wcs(); let (axis_x, axis_y) = circle.axes_wcs(); @@ -220,3 +271,94 @@ impl crate::entities::traits::MassPropsCalc for Circle { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_circle_segments_scales_with_zoom() { + let r = 10.0; + // Default / invalid cases return baseline floor of 48. + assert_eq!(circle_segments(r, None), 48); + assert_eq!(circle_segments(r, Some(-1.0)), 48); + assert_eq!(circle_segments(r, Some(0.0)), 48); + assert_eq!(circle_segments(r, Some(f32::NAN)), 48); + assert_eq!(circle_segments(r, Some(f32::INFINITY)), 48); + + // Zoomed far out: screen radius is tiny, capped at floor 48. + assert_eq!(circle_segments(r, Some(10.0)), 48); + assert_eq!(circle_segments(r, Some(2.0)), 48); + + // Zooming closer increases segments monotonically. + let seg_0_1 = circle_segments(r, Some(0.1)); + let seg_0_01 = circle_segments(r, Some(0.01)); + let seg_0_001 = circle_segments(r, Some(0.001)); + let seg_0_0001 = circle_segments(r, Some(0.0001)); + + assert!(seg_0_1 >= 48, "seg_0_1 was {seg_0_1}"); + assert!(seg_0_01 > seg_0_1, "seg_0_01 ({seg_0_01}) <= seg_0_1 ({seg_0_1})"); + assert!(seg_0_001 > seg_0_01, "seg_0_001 ({seg_0_001}) <= seg_0_01 ({seg_0_01})"); + assert!(seg_0_0001 > seg_0_001, "seg_0_0001 ({seg_0_0001}) <= seg_0_001 ({seg_0_001})"); + + // Extreme zoom in hits maximum cap of 4096. + assert_eq!(circle_segments(r, Some(1e-9)), 4096); + } + + #[test] + fn test_circle_segments_scales_with_radius() { + let wpp = Some(0.1); + let seg_small = circle_segments(1.0, wpp); + let seg_med = circle_segments(20.0, wpp); + let seg_large = circle_segments(100.0, wpp); + let seg_huge = circle_segments(500.0, wpp); + + assert_eq!(seg_small, 48); + assert!(seg_med > seg_small, "{seg_med} <= {seg_small}"); + assert!(seg_large > seg_med, "{seg_large} <= {seg_med}"); + assert!(seg_huge > seg_large, "{seg_huge} <= {seg_large}"); + } + + #[test] + fn test_circle_to_render_points_increase_with_zoom() { + let mut circle = Circle::default(); + circle.radius = 10.0; + + let far_render = to_render_with_wpp(&circle, Some(1.0)); + let close_render = to_render_with_wpp(&circle, Some(0.01)); + + let far_count = match far_render.object { + RenderObject::Lines(pts) => pts.len(), + _ => panic!("Expected RenderObject::Lines"), + }; + let close_count = match close_render.object { + RenderObject::Lines(pts) => pts.len(), + _ => panic!("Expected RenderObject::Lines"), + }; + + assert_eq!(far_count, 49); // 48 segments + 1 closing vertex + assert!(close_count > far_count, "close_count was {close_count} vs far_count {far_count}"); + } + + #[test] + fn test_thick_circle_to_render_points_increase_with_zoom() { + let mut circle = Circle::default(); + circle.radius = 10.0; + circle.thickness = 2.0; + + let far_render = to_render_with_wpp(&circle, Some(1.0)); + let close_render = to_render_with_wpp(&circle, Some(0.01)); + + let far_count = match far_render.object { + RenderObject::Lines(pts) => pts.len(), + _ => panic!("Expected RenderObject::Lines"), + }; + let close_count = match close_render.object { + RenderObject::Lines(pts) => pts.len(), + _ => panic!("Expected RenderObject::Lines"), + }; + + assert!(close_count > far_count, "{close_count} <= {far_count}"); + } +} + diff --git a/src/entities/common.rs b/src/entities/common.rs index 4c8c98f6c..1d1fbee41 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -114,7 +114,11 @@ pub fn format_length(value: f64) -> String { let per_foot = 12.0 * scale; let feet = (total / per_foot).trunc(); let rem = (total - feet * per_foot) / scale; - format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem) + if feet == 0.0 { + format!("{}{:.*}\"", sign, prec, rem) + } else { + format!("{}{:.0}'-{:.*}\"", sign, feet, prec, rem) + } } 4 | 5 => { // Architectural and fractional formats use 1/64-inch resolution. @@ -142,6 +146,9 @@ pub fn format_length(value: f64) -> String { }; let unit_suffix = if ctx.lunits == 4 { "\"" } else { "" }; match feet { + Some(f) if f == 0.0 => { + format!("{}{:.0}{}{}", sign, whole, frac_str, unit_suffix) + } Some(f) => format!("{}{:.0}'-{:.0}{}{}", sign, f, whole, frac_str, unit_suffix), None => format!("{}{:.0}{}", sign, whole, frac_str), } @@ -702,6 +709,30 @@ pub fn parse_angle_deg(value: &str) -> Option { /// there is one obvious place to see that the maths moved out. pub use cadkernel::geom2d::BulgeArc; +/// Convert a 2D BulgeArc into a 3D TangentGeom::Arc with its world center, +/// plane axes, radius, and counter-clockwise start/end sweep angles. +pub fn bulge_arc_to_tangent( + arc: &BulgeArc, + to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), + normal: (f64, f64, f64), +) -> crate::scene::model::wire_model::TangentGeom { + let (cwx, cwy, cwz) = to_wcs(arc.center[0], arc.center[1]); + let (ax, ay) = crate::scene::view::transform::ocs_axes(normal); + let (sa, ea) = if arc.sweep >= 0.0 { + (arc.start_angle, arc.end_angle) + } else { + (arc.end_angle, arc.start_angle) + }; + crate::scene::model::wire_model::TangentGeom::Arc { + center: [cwx, cwy, cwz], + axis_x: [ax.0, ax.1, ax.2], + axis_y: [ay.0, ay.1, ay.2], + radius: arc.radius, + start_angle: sa, + end_angle: ea, + } +} + /// Triangulate the solid bands a `wide_fills` returns into the flat WCS f64 /// triangle list `RenderEntity::pick_tris` carries, so a wide polyline is /// selectable across the band it draws and not just along its centreline. @@ -1078,7 +1109,9 @@ mod length_format_tests { #[test] fn architectural_carries_into_feet() { - assert_eq!(with_units(4, 4, 11.99), "0'-11 63/64\""); + assert_eq!(with_units(4, 4, 11.99), "11 63/64\""); + assert_eq!(with_units(4, 4, 0.5), "0 1/2\""); + assert_eq!(with_units(4, 4, -9.25), "-9 1/4\""); assert_eq!(with_units(4, 4, 11.999), "1'-0\""); assert_eq!(with_units(4, 4, 23.999), "2'-0\""); assert_eq!(with_units(4, 4, 66.5), "5'-6 1/2\""); @@ -1087,7 +1120,9 @@ mod length_format_tests { #[test] fn engineering_carries_into_feet() { - assert_eq!(with_units(3, 1, 11.94), "0'-11.9\""); + assert_eq!(with_units(3, 1, 11.94), "11.9\""); + assert_eq!(with_units(3, 1, 0.5), "0.5\""); + assert_eq!(with_units(3, 1, -9.25), "-9.3\""); assert_eq!(with_units(3, 1, 11.99), "1'-0.0\""); assert_eq!(with_units(3, 1, 23.99), "2'-0.0\""); assert_eq!(with_units(3, 2, 11.999), "1'-0.00\""); diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index fb8f7cde4..6530f9e38 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -294,16 +294,16 @@ fn apply_axis_grip(ell: &mut Ellipse, grip_id: usize, point: glam::DVec3) { fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) { match (grip_id, apply) { (0, GripApply::Translate(d)) => { - ell.center.x += d.x as f64; - ell.center.y += d.y as f64; - ell.center.z += d.z as f64; + ell.center.x += d.x; + ell.center.y += d.y; + ell.center.z += d.z; } (0, GripApply::Absolute(p)) => { - ell.center.x = p.x as f64; - ell.center.y = p.y as f64; - ell.center.z = p.z as f64; + ell.center.x = p.x; + ell.center.y = p.y; + ell.center.z = p.z; } - (1 | 2 | 3 | 4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p), + (1..=4, GripApply::Absolute(p)) => apply_axis_grip(ell, grip_id, p), (5 | 6, GripApply::Absolute(p)) => { let Some(parameter) = parameter_at_point(ell, p) else { return; diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 77a185326..8b125fe39 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -351,11 +351,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity { } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tgs.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); for s in arc .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) .into_iter() @@ -407,11 +403,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity { [v1.location.x, v1.location.y], v0.bulge, ) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tgs.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); } } let (pts, widths) = crate::entities::common::tapered_band_points( @@ -470,11 +462,7 @@ fn to_render(pline: &LwPolyline, fill_mode: bool) -> RenderEntity { } let (wx1, wy1, wz1) = to_wcs(ox1, oy1); kv.push([wx1, wy1, wz1]); - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tgs.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); } if i + 1 < seg_count { pts.push([f64::NAN; 3]); @@ -565,11 +553,8 @@ fn centerline_metadata( [end.location.x, end.location.y], start.bulge, ) { - let center = to_wcs(arc.center[0], arc.center[1]); - tangents.push(TangentGeom::Circle { - center: [center.0 as f32, center.1 as f32, center.2 as f32], - radius: arc.radius as f32, - }); + let normal = (pline.normal.x, pline.normal.y, pline.normal.z); + tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, to_wcs, normal)); } if index == 0 { key_vertices.push([p0.0, p0.1, p0.2]); diff --git a/src/entities/mesh.rs b/src/entities/mesh.rs index af3775653..e11ebed08 100644 --- a/src/entities/mesh.rs +++ b/src/entities/mesh.rs @@ -991,6 +991,67 @@ fn display_mesh(mesh: &Mesh) -> RefinedMesh { refined } +/// Convert a closed indexed mesh entity to an exact planar-faced B-rep. +/// Open, non-manifold, non-planar, and degenerate meshes return `None` so a +/// modelling command can leave the source untouched. +pub(crate) fn closed_mesh_body(entity: &acadrust::EntityType) -> Option { + match entity { + acadrust::EntityType::Mesh(mesh) => { + let base = base_refined_mesh(mesh); + cadkernel::brep::make::faceted_solid(&base.vertices, &base.faces) + } + acadrust::EntityType::PolygonMesh(mesh) => { + if !mesh.is_closed_m() || !mesh.is_closed_n() { + return None; + } + let m = mesh.m_vertex_count.max(0) as usize; + let n = mesh.n_vertex_count.max(0) as usize; + let vertices = mesh + .vertices + .iter() + .take(m.saturating_mul(n)) + .map(|vertex| v3(&vertex.location)) + .collect::>(); + if m < 2 || n < 2 || vertices.len() != m.saturating_mul(n) { + return None; + } + let mut faces = Vec::with_capacity(m.saturating_mul(n)); + for row in 0..m { + for column in 0..n { + faces.push(vec![ + row * n + column, + ((row + 1) % m) * n + column, + ((row + 1) % m) * n + (column + 1) % n, + row * n + (column + 1) % n, + ]); + } + } + cadkernel::brep::make::faceted_solid(&vertices, &faces) + } + acadrust::EntityType::PolyfaceMesh(mesh) => { + let vertices = mesh + .vertices + .iter() + .map(|vertex| v3(&vertex.location)) + .collect::>(); + let faces = mesh + .faces + .iter() + .filter_map(|face| { + let indices = [face.index1, face.index2, face.index3, face.index4] + .into_iter() + .filter(|index| *index != 0) + .map(|index| (index.unsigned_abs() as usize).checked_sub(1)) + .collect::>>()?; + (indices.len() >= 3).then_some(indices) + }) + .collect::>(); + cadkernel::brep::make::faceted_solid(&vertices, &faces) + } + _ => None, + } +} + fn face_triangle_indices( vertices: &[[f64; 3]], faces: &[Vec], diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index d9cd71404..de67a35dd 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -248,7 +248,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity { ); } let (tangent_geoms, key_vertices) = - centerline_metadata_2d(verts, pl.is_closed(), &to_wcs); + centerline_metadata_2d(verts, pl.is_closed(), &to_wcs, normal); return RenderEntity { pick_tris: Vec::new(), object: RenderObject::BoundaryLines { @@ -296,11 +296,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity { } else if let Some(arc) = crate::entities::common::BulgeArc::from_bulge([ox0, oy0], [ox1, oy1], bulge) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tgs.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tgs.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); for s in arc .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) .into_iter() @@ -378,11 +374,7 @@ fn tessellate_polyline2d(pl: &Polyline2D, fill_mode: bool) -> RenderEntity { [v1.location.x, v1.location.y], bulge, ) { - let (wcx, wcy, wcz) = to_wcs(arc.center[0], arc.center[1]); - tangents.push(TangentGeom::Circle { - center: [wcx as f32, wcy as f32, wcz as f32], - radius: arc.radius as f32, - }); + tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, &to_wcs, normal)); } if i == 0 { @@ -464,6 +456,7 @@ fn centerline_metadata_2d( verts: &[acadrust::entities::Vertex2D], closed: bool, to_wcs: &dyn Fn(f64, f64) -> (f64, f64, f64), + normal: (f64, f64, f64), ) -> (Vec, Vec<[f64; 3]>) { let count = verts.len(); let segment_count = if closed { @@ -488,11 +481,7 @@ fn centerline_metadata_2d( [end.location.x, end.location.y], start.bulge, ) { - let center = to_wcs(arc.center[0], arc.center[1]); - tangents.push(TangentGeom::Circle { - center: [center.0 as f32, center.1 as f32, center.2 as f32], - radius: arc.radius as f32, - }); + tangents.push(crate::entities::common::bulge_arc_to_tangent(&arc, to_wcs, normal)); } if index == 0 { key_vertices.push([p0.0, p0.1, p0.2]); diff --git a/src/io/macos_open_events.rs b/src/io/macos_open_events.rs deleted file mode 100644 index ae1fd93e2..000000000 --- a/src/io/macos_open_events.rs +++ /dev/null @@ -1,283 +0,0 @@ -//! macOS: catch a `kAEOpenDocuments` Apple Event sent directly to this -//! already-running process — the *warm* half of #1039 ("Cannot OPEN WITH in -//! FINDER MAC OS"). -//! -//! [`src/bin/ocs_launcher.rs`] is the `.app`'s actual `CFBundleExecutable` -//! and stays running for the whole session — Launch Services keeps -//! addressing every open (cold *or* warm) to that one process's Application -//! Serial Number, so it relays each one to this binary over the existing -//! `io::single_instance` port rather than over an Apple Event of its own. -//! -//! That covers every open Finder initiates. What it doesn't cover: a plain -//! `open -a OpenCADStudio-App file.dwg`, or an AppleScript `tell application -//! … to open`, aimed at this binary directly rather than at the launcher — -//! bypassing the launcher (and its relay) entirely. Verified against a real -//! second "Open With" while already running (not assumed): Launch Services -//! can deliver a `kAEOpenDocuments` event straight to an already-running, -//! cleanly-launched instance of this binary, and without a handler here that -//! goes unhandled the same way the original bug described. -//! -//! The standard fix — implement `application:openURLs:` on this app's own -//! `NSApplicationDelegate` — isn't available here: winit already installs -//! its own delegate (`platform_impl::macos::app_state::ApplicationDelegate`) -//! to own window/lifecycle events, `NSApplication` has exactly one delegate -//! slot, and winit's class doesn't implement the open-document method. So -//! this bypasses the delegate protocol and registers directly with the -//! lower-level Apple Event Manager (`AEInstallEventHandler`) instead — the -//! same mechanism AppKit's delegate forwarding is itself built on, and -//! independent of who owns the delegate. -//! -//! Registration timing is counter-intuitive and was pinned down by testing -//! against a real `open -a`, not by reasoning from Apple's docs: registering -//! before anything has touched `NSApplication` (e.g. from `main()`, before -//! `app::run()`) does not stick — winit's own `EventLoop::new()`/`-run` -//! startup sequence re-registers AppKit's own default (no-op) handler for -//! the same event afterwards and silently wins. So [`subscribe`]'s stream -//! registers on its own first poll instead, which iced only reaches once the -//! window/application lifecycle has already finished starting — late enough -//! that nothing overwrites it afterwards. (A *cold*-launch event specifically -//! still isn't caught this way, even registered as the first statement of -//! `main()` — which is exactly why that case needs the separate launcher.) - -use std::ffi::{c_void, OsString}; -use std::os::raw::c_long; -use std::os::unix::ffi::OsStringExt; -use std::path::PathBuf; -use std::sync::mpsc::{channel, Sender}; -use std::sync::{Mutex, OnceLock}; - -// ── Apple Event Manager FFI ───────────────────────────────────────────── -// -// Hand-rolled, matching the existing macOS FFI in `file_association.rs` -// rather than pulling in an objc2 dependency for a handful of C calls. Types -// and constants below are taken from the real SDK headers (verified against -// `AE.framework/Headers/{AppleEvents,AEDataModel}.h`, not from memory) so the -// field widths match this target's actual C ABI. - -type OSErr = i16; -type OSType = u32; -type AEEventClass = OSType; -type AEEventID = OSType; -type AEKeyword = OSType; -type DescType = OSType; -/// `SRefCon` is `void *` on this (LP64) target — only ever passed as `0` -/// here, so its exact pointee type doesn't matter. -type SRefCon = *mut c_void; -/// Carbon's `Size` is `typedef long Size;` — 8 bytes on 64-bit macOS. -type Size = c_long; -type Boolean = u8; - -/// Layout must match the real C `struct AEDesc` (`descriptorType` then -/// `dataHandle`) exactly, including the compiler-inserted padding before the -/// 8-byte pointer field — `repr(C)` reproduces that automatically here. -#[repr(C)] -struct AEDesc { - descriptor_type: DescType, - data_handle: *mut c_void, -} -type AppleEvent = AEDesc; -type AEDescList = AEDesc; - -const K_CORE_EVENT_CLASS: AEEventClass = 0x6165_7674; // 'aevt' -const K_AE_OPEN_DOCUMENTS: AEEventID = 0x6F64_6F63; // 'odoc' -const KEY_DIRECT_OBJECT: AEKeyword = 0x2D2D_2D2D; // '----' -const TYPE_AE_LIST: DescType = 0x6C69_7374; // 'list' -/// Coercing a list item to this type is what makes `AEGetNthDesc` hand back -/// the file's location — as a percent-encoded `file://` URL in practice -/// (measured against a real Finder-equivalent open), not the plain path the -/// `typeFileURL` comment in the real `AEDataModel.h` claims. -const TYPE_FILE_URL: DescType = 0x6675_726C; // 'furl' - -type AEEventHandlerProcPtr = unsafe extern "C" fn( - the_apple_event: *const AppleEvent, - reply: *mut AppleEvent, - handler_refcon: SRefCon, -) -> OSErr; - -#[link(name = "CoreServices", kind = "framework")] -extern "C" { - fn AEInstallEventHandler( - the_ae_event_class: AEEventClass, - the_ae_event_id: AEEventID, - handler: AEEventHandlerProcPtr, - handler_refcon: SRefCon, - is_sys_handler: Boolean, - ) -> OSErr; - - fn AEGetParamDesc( - the_apple_event: *const AppleEvent, - the_ae_keyword: AEKeyword, - desired_type: DescType, - result: *mut AEDesc, - ) -> OSErr; - - fn AECountItems(the_ae_desc_list: *const AEDescList, the_count: *mut c_long) -> OSErr; - - fn AEGetNthDesc( - the_ae_desc_list: *const AEDescList, - index: c_long, - desired_type: DescType, - the_ae_keyword: *mut AEKeyword, - result: *mut AEDesc, - ) -> OSErr; - - fn AEGetDescDataSize(the_ae_desc: *const AEDesc) -> Size; - - fn AEGetDescData(the_ae_desc: *const AEDesc, data_ptr: *mut c_void, maximum_size: Size) -> OSErr; - - fn AEDisposeDesc(the_ae_desc: *mut AEDesc) -> OSErr; -} - -fn null_desc() -> AEDesc { - AEDesc { - descriptor_type: 0, - data_handle: std::ptr::null_mut(), - } -} - -/// The Apple Event Manager callback. Runs synchronously, inside whichever -/// native run-loop pass just received the event — kept short, and every path -/// just gets handed off to [`send_opened_path`] rather than touched further -/// here. -unsafe extern "C" fn handle_open_documents( - the_apple_event: *const AppleEvent, - _reply: *mut AppleEvent, - _handler_refcon: SRefCon, -) -> OSErr { - let mut doc_list = null_desc(); - let err = - unsafe { AEGetParamDesc(the_apple_event, KEY_DIRECT_OBJECT, TYPE_AE_LIST, &mut doc_list) }; - if err != 0 { - return err; - } - - let mut count: c_long = 0; - if unsafe { AECountItems(&doc_list, &mut count) } == 0 { - for index in 1..=count { - let mut item = null_desc(); - let mut keyword: AEKeyword = 0; - let got = - unsafe { AEGetNthDesc(&doc_list, index, TYPE_FILE_URL, &mut keyword, &mut item) }; - if got == 0 { - let size = unsafe { AEGetDescDataSize(&item) }; - if size > 0 { - let mut buf = vec![0u8; size as usize]; - let read = - unsafe { AEGetDescData(&item, buf.as_mut_ptr() as *mut c_void, size) }; - if read == 0 { - if let Some(path) = path_from_bytes(buf) { - send_opened_path(path); - } - } - } - unsafe { AEDisposeDesc(&mut item) }; - } - } - } - unsafe { AEDisposeDesc(&mut doc_list) }; - 0 // noErr -} - -/// `typeFileURL` data is a percent-encoded `file://` URL (measured, not -/// assumed — see the module doc). `file:///abs/path` has an empty host, so -/// the part after the scheme already starts with the path's own leading `/`. -fn path_from_bytes(bytes: Vec) -> Option { - let s = std::str::from_utf8(&bytes).ok()?; - let encoded_path = s.strip_prefix("file://")?; - let decoded = percent_decode(encoded_path.as_bytes()); - if decoded.is_empty() { - return None; - } - Some(PathBuf::from(OsString::from_vec(decoded))) -} - -/// Minimal `%XX` decoder. Decodes byte-for-byte rather than per-codepoint, so -/// a multi-byte UTF-8 sequence in the original filename (each byte percent- -/// encoded separately) round-trips correctly through `OsString::from_vec` -/// even though the decoded bytes aren't validated as UTF-8 here. -fn percent_decode(input: &[u8]) -> Vec { - let mut out = Vec::with_capacity(input.len()); - let mut i = 0; - while i < input.len() { - if input[i] == b'%' && i + 2 < input.len() { - if let (Some(hi), Some(lo)) = (hex_val(input[i + 1]), hex_val(input[i + 2])) { - out.push((hi << 4) | lo); - i += 3; - continue; - } - } - out.push(input[i]); - i += 1; - } - out -} - -fn hex_val(b: u8) -> Option { - match b { - b'0'..=b'9' => Some(b - b'0'), - b'a'..=b'f' => Some(b - b'a' + 10), - b'A'..=b'F' => Some(b - b'A' + 10), - _ => None, - } -} - -// ── Apple Event callback → iced Subscription ──────────────────────────── -// -// Same shape as `single_instance`'s "blocking OS primitive → iced stream" -// bridge: a channel filled from an external source (there, a TCP accept loop -// on its own thread; here, the AE callback), drained on a dedicated thread so -// the async wrapper in [`subscribe`] never blocks. - -static SENDER: OnceLock>> = OnceLock::new(); - -fn send_opened_path(path: PathBuf) { - if let Some(tx) = SENDER.get() { - let _ = tx.lock().unwrap_or_else(|e| e.into_inner()).send(path); - } -} - -/// Files handed to this already-running process via a warm `kAEOpenDocuments` -/// Apple Event. Feeds the same [`crate::app::Message`] path as -/// `single_instance::subscribe` (`Message::OpenExternal`) — see -/// `app::view::subscription`. -pub fn subscribe() -> iced::Subscription { - iced::Subscription::run(worker) -} - -type PathSender = iced::futures::channel::mpsc::Sender; - -fn worker() -> impl iced::futures::Stream { - iced::stream::channel(8, drain_opened_paths) -} - -/// Registers the Apple Event handler on this stream's first poll (see the -/// module doc for why here, of all places, rather than at process startup), -/// then forwards every path the handler receives for the rest of the -/// process's life. -async fn drain_opened_paths(out: PathSender) { - let (tx, rx) = channel(); - if SENDER.set(Mutex::new(tx)).is_err() { - // `Subscription::run` keys a recipe's identity off `worker`'s function - // pointer, so iced runs this at most once — this is just a guard - // against that invariant changing out from under us. Park rather than - // end the stream, so iced does not re-run the recipe. - std::future::pending::<()>().await; - return; - } - unsafe { - AEInstallEventHandler( - K_CORE_EVENT_CLASS, - K_AE_OPEN_DOCUMENTS, - handle_open_documents, - std::ptr::null_mut(), - 0, - ); - } - std::thread::spawn(move || { - let mut out = out; - for path in rx { - let _ = out.try_send(path); - } - }); - std::future::pending::<()>().await; -} diff --git a/src/io/mod.rs b/src/io/mod.rs index 6cc943eb5..0a49833ac 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -9,8 +9,6 @@ pub mod edit_lock; pub mod obj; #[cfg(not(target_arch = "wasm32"))] pub mod single_instance; -#[cfg(target_os = "macos")] -pub mod macos_open_events; pub mod pdf_export; pub mod plot_style; pub mod print_to_printer; @@ -2116,33 +2114,8 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { || !finite_coord(a.radius) || !a.start_angle.is_finite() || !a.end_angle.is_finite() - // Same degenerate-curve guard as Circle. || a.radius.abs() < 1.0e-10 || a.radius.abs() > 1.0e10 - // Zero-sweep arc (start_angle == end_angle, modulo 2π) collapses - // to a single point in WCS — a three-point circle fit on - // coincident vertices recurses unboundedly in parameter_division. - || (a.end_angle - a.start_angle).abs() < 1.0e-9 - // Near-zero sweep is the same trap with a wider mouth: a tiny but - // non-zero sweep (e.g. 1.6e-6 rad) still places start/mid/end - // within the coincidence tolerance, so sampling - // recurses and allocates until OOM. Gate on arc *length* - // (radius × sweep), not sweep alone, so a legitimately large- - // radius small-sweep arc (still a visible curve) survives while - // sub-precision arcs are dropped. - || a.radius.abs() * (a.end_angle - a.start_angle).abs() < 1.0e-6 - // Near-collinear sample points: even when the arc *length* clears - // the floor above, a small sweep over a modest radius leaves - // start/mid/end almost on one line (a 35-unit, 6.5e-7-rad arc has - // arc length 2.3e-5 — past the gate — yet bows off its chord by - // only ~2e-12). A three-point circle fit then returns a - // near-infinite radius and `parameter_division` subdivides without - // bound. Gate on the sagitta (chord height = r·(1−cos(sweep/2))), - // the true measure of how far the arc departs a straight line and - // of the fit's conditioning. - || a.radius.abs() - * (1.0 - ((a.end_angle - a.start_angle).abs() * 0.5).cos()) - < 1.0e-6 || !finite_unit_normal(&a.normal) } E::Ellipse(e) => { @@ -2172,32 +2145,15 @@ pub(crate) fn is_entity_corrupt(e: &EntityType) -> bool { let n = s.control_points.len(); let degree_bad = s.degree < 1; let deg = s.degree.max(0) as usize; + let compact_periodic_knots = s.flags.periodic && s.knots.len() == n.saturating_add(1); let knots_bad = !s.knots.is_empty() && (s.knots.iter().any(|k| !k.is_finite()) || s.knots.windows(2).any(|w| w[1] < w[0]) - || s.knots.len() != n + deg + 1); - // Degenerate: every control point collapses onto (nearly) the same - // point, so the curve has zero length. A three-point fit - // `parameter_division` never converges on it and the tessellation - // hangs — a periodic 9-point spline pinned at the origin is the seen - // case. Reject when the control-point extent is sub-precision. - let degenerate_extent = n >= 2 && { - let (mut mn, mut mx) = ([f64::MAX; 3], [f64::MIN; 3]); - for p in &s.control_points { - mn[0] = mn[0].min(p.x); - mx[0] = mx[0].max(p.x); - mn[1] = mn[1].min(p.y); - mx[1] = mx[1].max(p.y); - mn[2] = mn[2].min(p.z); - mx[2] = mx[2].max(p.z); - } - (mx[0] - mn[0]).max(mx[1] - mn[1]).max(mx[2] - mn[2]) < 1.0e-6 - }; + || (!compact_periodic_knots && s.knots.len() != n + deg + 1)); n >= MAX_VERTS || degree_bad || s.control_points.iter().any(|p| !finite_vec3(p)) || knots_bad - || degenerate_extent } _ => false, } @@ -2411,38 +2367,29 @@ mod corrupt_guard_tests { use acadrust::entities::{Arc, EntityType, Spline}; use acadrust::types::Vector3; - // A near-zero-sweep arc: sweep 1.56e-6 rad on a 3.9e-3 radius. The angles - // are individually finite and the radius is in range, so the old - // (end-start) < 1e-9 check passed it through — but start/mid/end land - // within the coincidence tolerance and sampling allocates - // until OOM. The arc-length floor must reject it. + // Small but finite arcs are valid records. Kernel tessellation is bounded, + // so opening must retain them instead of treating their size as corruption. #[test] - fn rejects_near_degenerate_arc() { + fn keeps_small_finite_arc() { let mut a = Arc::new(); a.center = Vector3::new(2880.84, 891.83, 0.0); a.radius = 0.0038974142851181423; a.start_angle = 1.0401656235942365; a.end_angle = 1.0401671831670538; a.normal = Vector3::new(0.0, 0.0, 1.0); - assert!(is_entity_corrupt(&EntityType::Arc(a))); + assert!(!is_entity_corrupt(&EntityType::Arc(a))); } - // A 35-unit-radius arc sweeping 6.5e-7 rad has arc length 2.3e-5 — past the - // arc-length floor — yet its start/mid/end bow off the chord by only ~2e-12, - // so a three-point circle fit blows up and sampling hangs. The - // sagitta floor must reject it where the arc-length floor alone does not. + // A nearly straight arc still carries valid source geometry. #[test] - fn rejects_near_collinear_arc() { + fn keeps_nearly_straight_arc() { let mut a = Arc::new(); a.center = Vector3::new(551435.3071786845, 4051623.7156955916, 0.0); a.radius = 35.0; a.start_angle = 5.823361856481176; a.end_angle = 5.823362506017916; a.normal = Vector3::new(0.0, 0.0, 1.0); - // Sanity: arc length clears the old gate, proving the sagitta gate is - // what catches this one. - assert!(a.radius * (a.end_angle - a.start_angle).abs() > 1.0e-6); - assert!(is_entity_corrupt(&EntityType::Arc(a))); + assert!(!is_entity_corrupt(&EntityType::Arc(a))); } // A large-radius small-sweep arc is still a visible curve and must survive: @@ -2466,14 +2413,25 @@ mod corrupt_guard_tests { assert!(is_entity_corrupt(&EntityType::Spline(s))); } - // A periodic spline whose control points all collapse onto (nearly) one - // point has zero length; sampling never converges and the - // tessellation hangs. The control-point extent floor must reject it. + // A collapsed spline is still a valid source record. Kernel subdivision + // has a fixed depth bound and can process it safely. #[test] - fn rejects_degenerate_point_spline() { + fn keeps_degenerate_point_spline() { let pts = vec![Vector3::new(1e-12, -1e-12, 0.0); 9]; let s = Spline::from_control_points(3, pts); - assert!(is_entity_corrupt(&EntityType::Spline(s))); + assert!(!is_entity_corrupt(&EntityType::Spline(s))); + } + + #[test] + fn keeps_compact_periodic_spline_knots() { + let pts = (0..12) + .map(|index| Vector3::new(index as f64, (index % 3) as f64, 0.0)) + .collect(); + let mut s = Spline::from_control_points(2, pts); + s.flags.closed = true; + s.flags.periodic = true; + s.knots = (0..13).map(|value| value as f64).collect(); + assert!(!is_entity_corrupt(&EntityType::Spline(s))); } // A normal cubic spline (4 control points, valid clamped knots) survives. diff --git a/src/io/paper_sizes.rs b/src/io/paper_sizes.rs index 8fcbefde8..916a961e3 100644 --- a/src/io/paper_sizes.rs +++ b/src/io/paper_sizes.rs @@ -1,4 +1,4 @@ -//! ISO paper sizes and sheet orientation for window plotting. +//! Standard paper sizes and sheet orientation for window plotting. #[derive(Copy, Clone, PartialEq, Eq, Debug)] pub enum PaperSize { @@ -7,15 +7,21 @@ pub enum PaperSize { A2, A1, A0, + Letter, + Legal, + Tabloid, } impl PaperSize { - pub const ALL: [PaperSize; 5] = [ + pub const ALL: [PaperSize; 8] = [ PaperSize::A4, PaperSize::A3, PaperSize::A2, PaperSize::A1, PaperSize::A0, + PaperSize::Letter, + PaperSize::Legal, + PaperSize::Tabloid, ]; pub fn label(self) -> &'static str { @@ -25,6 +31,9 @@ impl PaperSize { PaperSize::A2 => "A2", PaperSize::A1 => "A1", PaperSize::A0 => "A0", + PaperSize::Letter => "Letter", + PaperSize::Legal => "Legal", + PaperSize::Tabloid => "Tabloid", } } @@ -36,6 +45,9 @@ impl PaperSize { PaperSize::A2 => (420.0, 594.0), PaperSize::A1 => (594.0, 841.0), PaperSize::A0 => (841.0, 1189.0), + PaperSize::Letter => (215.9, 279.4), + PaperSize::Legal => (215.9, 355.6), + PaperSize::Tabloid => (279.4, 431.8), } } } @@ -94,10 +106,13 @@ mod tests { use super::*; #[test] - fn iso_dimensions_and_orientation() { + fn paper_dimensions_and_orientation() { assert_eq!(PaperSize::A4.dimensions_mm(), (210.0, 297.0)); assert_eq!(PaperSize::A0.dimensions_mm(), (841.0, 1189.0)); - assert_eq!(PaperSize::ALL.len(), 5); + assert_eq!(PaperSize::Letter.dimensions_mm(), (215.9, 279.4)); + assert_eq!(PaperSize::Legal.dimensions_mm(), (215.9, 355.6)); + assert_eq!(PaperSize::Tabloid.dimensions_mm(), (279.4, 431.8)); + assert_eq!(PaperSize::ALL.len(), 8); assert_eq!(PaperSize::A3.label(), "A3"); // Portrait keeps (w,h); landscape swaps. assert_eq!(sheet_mm(PaperSize::A4, Orientation::Portrait), (210.0, 297.0)); diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 0ff656d13..10b72da48 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -20,8 +20,6 @@ use printpdf::{ PdfFontHandle, PdfPage, PdfSaveOptions, Point, Polygon, PolygonRing, Pt, Rgb, TextItem, WindingOrder, }; -#[cfg(not(target_arch = "wasm32"))] -use std::io::Write; use std::path::Path; #[derive(Clone, Debug)] @@ -175,8 +173,7 @@ pub fn export_pdf( plot_style, options, ); - let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?; - file.write_all(&bytes).map_err(|e| e.to_string()) + write_pdf_atomically(path, &bytes) } /// Export several independently sized pages into one PDF file. @@ -190,8 +187,22 @@ pub fn export_pdf_pages( return Err("No pages were selected.".into()); } let bytes = build_pdf_pages(pages, plot_style); - let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?; - file.write_all(&bytes).map_err(|e| e.to_string()) + write_pdf_atomically(path, &bytes) +} + +/// Write a complete PDF beside the destination, then replace it atomically. +#[cfg(not(target_arch = "wasm32"))] +fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { + let temp_path = super::save_temp_path(path); + if let Err(error) = std::fs::write(&temp_path, bytes) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("Failed to write PDF data: {error}")); + } + if let Err(error) = super::replace_save_file(&temp_path, path) { + let _ = std::fs::remove_file(&temp_path); + return Err(format!("Failed to replace PDF file: {error}")); + } + Ok(()) } /// Show a parented PDF save-file dialog and return the chosen path. @@ -1505,6 +1516,24 @@ fn emit_text( mod tests { use super::*; + #[test] + fn atomic_write_replaces_an_existing_pdf() { + let path = std::env::temp_dir().join(format!( + "ocs-pdf-replace-{}-{}.pdf", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, b"old").unwrap(); + + write_pdf_atomically(&path, b"new").unwrap(); + + assert_eq!(std::fs::read(&path).unwrap(), b"new"); + std::fs::remove_file(path).unwrap(); + } + #[test] fn clip_and_scale_emit_pdf_bytes() { let w = PlotWire { diff --git a/src/io/single_instance.rs b/src/io/single_instance.rs index bef6fb88f..9883faa3e 100644 --- a/src/io/single_instance.rs +++ b/src/io/single_instance.rs @@ -59,16 +59,8 @@ pub enum Claim { Existing(TcpStream), } -/// The stable path this rendezvous is anchored to: normally the running -/// executable itself, but on macOS, when it lives inside a `.app` bundle's -/// `Contents/MacOS/`, the bundle's own path instead. That makes this -/// launcher shim (`src/bin/ocs_launcher.rs`) and the real `OpenCADStudio-App` -/// binary it relays to — two different executables inside the same bundle — -/// rendezvous on the same port (#1039), rather than each computing a -/// different one from their own distinct `current_exe()`. Falls back to the -/// executable path itself when there's no enclosing bundle (a `cargo run` -/// dev binary, or a non-macOS platform), preserving the anti-collision -/// guarantee below unchanged there. +/// Use the app bundle path on macOS so its launcher and GUI share a port. +/// Development builds and other platforms remain scoped by executable path. fn rendezvous_anchor() -> PathBuf { let exe = std::env::current_exe().unwrap_or_default(); #[cfg(target_os = "macos")] @@ -148,13 +140,7 @@ pub fn claim() -> Claim { } } -/// Try to reach an already-running editor, without attempting to bind the -/// port ourselves. Unlike [`claim`], this never competes for the port — for -/// a caller that isn't itself prepared to serve it and go on to become -/// Primary (the macOS launcher shim, `src/bin/ocs_launcher.rs`, which relays -/// to a real editor process rather than being one), calling `claim` would -/// risk binding the port out from under the GUI process that's about to -/// start up and claim it for itself. +/// Reach an editor without claiming its port. Used by the macOS launcher. pub fn try_connect_existing() -> Option { TcpStream::connect_timeout(&addr(), IO_TIMEOUT).ok() } @@ -338,15 +324,15 @@ mod tests { } #[test] - fn rendezvous_key_carries_user_session_and_exe() { + fn rendezvous_key_carries_user_session_and_anchor() { // All three parts must be present, or the isolation the key exists for // is silently gone. let k = rendezvous_key(); assert_eq!(k.matches('|').count(), 2, "key shape changed: {k:?}"); - let exe = std::env::current_exe().unwrap(); + let anchor = rendezvous_anchor(); assert!( - k.ends_with(&*exe.to_string_lossy()), - "key must pin the executable: {k:?}" + k.ends_with(&*anchor.to_string_lossy()), + "key must pin the rendezvous anchor: {k:?}" ); } diff --git a/src/lib.rs b/src/lib.rs index 5a8bda72d..a02cd5bcb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,25 +1,26 @@ #![allow(non_snake_case)] +#![recursion_limit = "256"] pub mod app; -pub mod config; #[cfg(not(target_arch = "wasm32"))] pub mod cli; pub mod command; +pub mod config; +pub mod discussions; pub mod entities; pub mod i18n; pub mod io; #[cfg(not(target_arch = "wasm32"))] -pub(crate) mod network; -#[cfg(not(target_arch = "wasm32"))] pub mod mcp; pub mod modules; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) mod network; +pub mod par; pub mod patreon; -pub mod discussions; -pub mod videos; -pub mod plugin; pub mod perf; +pub mod plugin; pub mod scene; pub mod snap; -pub mod ui; -pub mod par; pub mod sys; +pub mod ui; +pub mod videos; diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index eda54347e..de350e331 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -2346,11 +2346,44 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "SLICE offset along X:" => Some(("draw", "slice-offset-along-x")), "SLICE offset along Y:" => Some(("draw", "slice-offset-along-y")), "SLICE offset along Z:" => Some(("draw", "slice-offset-along-z")), + "SLICE Select solids or surfaces to slice, then press Enter:" => Some(("draw", "slice-select-targets")), + "SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>:" => Some(("draw", "slice-first-point-or-method")), + "SLICE Specify first point on slicing plane:" => Some(("draw", "slice-first-plane-point")), + "SLICE Specify second point on slicing plane:" => Some(("draw", "slice-second-plane-point")), + "SLICE Specify third point on slicing plane:" => Some(("draw", "slice-third-plane-point")), + "SLICE Specify a point on the slicing plane:" => Some(("draw", "slice-point-on-plane")), + "SLICE Specify a point on the plane's normal axis:" => Some(("draw", "slice-point-on-normal-axis")), + "SLICE Specify a point on the current view plane:" => Some(("draw", "slice-point-on-view-plane")), + "SLICE Specify a point on the current UCS plane:" => Some(("draw", "slice-point-on-ucs-plane")), + "SLICE Select a planar curve object:" => Some(("draw", "slice-select-planar-object")), + "SLICE Select a planar surface:" => Some(("draw", "slice-select-planar-surface")), + "SLICE Select a cutting surface:" => Some(("draw", "slice-select-cutting-surface")), + "SLICE Specify a point on the desired side or [Both] :" => Some(("draw", "slice-select-side-or-both")), + "SLICE Select a sliced object to keep or [Both] :" => Some(("draw", "slice-select-object-or-both")), + "SLICE The selected surface is not planar. Select a planar surface:" => Some(("draw", "slice-surface-not-planar")), + "SLICE The selected surface does not define one supported cutting sheet. Select another surface:" => Some(("draw", "slice-surface-not-supported")), + "SLICE The selected object does not define a plane. Select a planar curve object:" => Some(("draw", "slice-object-not-planar")), + "planar Object" => Some(("draw", "slice-planar-object")), + "Zaxis" => Some(("draw", "slice-zaxis")), + "3points" => Some(("draw", "slice-three-points")), + "Both" => Some(("common", "both")), "SLICE failed — the plane may not cross the solid." => Some(("common", "slice-failed-the-plane-may-not-cross-the-solid")), "SLICE: could not determine the solid's extent." => Some(("common", "slice-could-not-determine-the-solid-s-extent")), "SLICE: cut at {ax}={value}, kept the {} half." => Some(("common", "slice-cut-at-ax-value-kept-the-half")), "SLICE: select exactly one solid created this session." => Some(("common", "slice-select-exactly-one-solid-created-this-se")), "SLICE: the plane does not cross the solid on the kept side." => Some(("common", "slice-the-plane-does-not-cross-the-solid-on-th")), + "SLICE: select at least one solid or surface." => Some(("common", "slice-select-at-least-one-target")), + "SLICE: the cutting plane is invalid." => Some(("common", "slice-invalid-plane")), + "SLICE: choose a point away from the cutting plane." => Some(("common", "slice-side-point-on-plane")), + "SLICE: the cutting plane does not cross every selected object; no object was changed." => Some(("common", "slice-plane-misses-target")), + "SLICE: the selected geometry has an unsupported surface intersection; no object was changed." => Some(("common", "slice-unsupported-intersection")), + "SLICE: the cutting plane is coincident with source geometry; no object was changed." => Some(("common", "slice-coincident-plane")), + "SLICE: the selected topology could not be split safely; no object was changed." => Some(("common", "slice-topology-refused")), + "SLICE: a result could not be encoded; no object was changed." => Some(("common", "slice-result-encode-failed")), + "SLICE: a result could not be displayed completely; no object was changed." => Some(("common", "slice-result-display-failed")), + "SLICE: a result could not be committed; all source objects were retained." => Some(("common", "slice-result-commit-failed")), + "SLICE: a retained result could not be updated." => Some(("common", "slice-retained-update-failed")), + "SLICE: created %{count} result object(s)." => Some(("common", "slice-created-results")), "SOLID Specify first point:" => Some(("draw", "solid-specify-first-point")), "SOLID Specify fourth point:" => Some(("draw", "solid-specify-fourth-point")), "SOLID Specify second point:" => Some(("draw", "solid-specify-second-point")), @@ -3871,8 +3904,19 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "CYLINDER Specify axis endpoint:" => Some(("model", "cylinder-specify-axis-endpoint")), "Elliptical" => Some(("model", "elliptical")), "Select a solid edge:" => Some(("model", "select-a-solid-edge")), + "Select an edge or [Chain/Loop/Radius]:" => Some(("model", "filletedge-select-edge-or-chain-loop-radius")), + "Select an edge chain or [Edge/Radius]:" => Some(("model", "filletedge-select-edge-chain-or-edge-radius")), + "Select edge of loop or [Edge/Chain/Radius]:" => Some(("model", "filletedge-select-loop-edge-or-edge-chain-radius")), + "Enter an option [Accept/Next] :" => Some(("model", "filletedge-enter-option-accept-next")), + "Press Enter to accept the fillet or [Radius]:" => Some(("model", "filletedge-accept-or-radius")), + "Enter fillet radius or [Expression] <{:.4}>:" => Some(("model", "filletedge-enter-radius-or-expression")), + "Specify Radius or [Expression] <{:.4}>:" => Some(("model", "filletedge-specify-radius-or-expression")), + "Enter expression:" => Some(("model", "filletedge-enter-expression")), "Specify fillet radius <{:.3}>:" => Some(("model", "specify-fillet-radius")), "Specify chamfer distance <{:.3}>:" => Some(("model", "specify-chamfer-distance")), + "A selected edge no longer belongs to the solid." => Some(("model", "filletedge-stale-edge")), + "The solid history could not be created." => Some(("model", "filletedge-history-create-failed")), + "The fillet history could not be recorded." => Some(("model", "filletedge-history-append-failed")), "POLYSOLID Height = {:.4}, Width = {:.4}, Justification = {}\n{}" => Some(("model", "polysolid-height-width-justification")), "Specify start point or [Object/Height/Width/Justify] :" => Some(("model", "specify-start-point-or-object-height-width-justify-object")), "Specify height <{:.4}>:" => Some(("model", "specify-height")), diff --git a/src/main.rs b/src/main.rs index f197aa6f9..a691981e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -134,7 +134,9 @@ fn main() -> iced::Result { Ok(text) => text .lines() .map(str::trim) - .filter(|l| !l.is_empty() && !l.starts_with('#')) + // Blank script rows are significant: they submit Enter to + // the active command (for example, accepting a preview). + .filter(|l| !l.starts_with('#') && !l.starts_with(';')) .map(str::to_string) .collect(), Err(e) => { diff --git a/src/mcp.rs b/src/mcp.rs index 887528b62..71866f815 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -22,11 +22,14 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28"; const MAX_REQUEST: usize = 1_048_576; const MAX_RESPONSE: u64 = 16 * 1024 * 1024; const CACHE_TTL_MS: u64 = 3_600_000; -const INSTRUCTIONS: &str = "Call ocs_sessions, then ocs_read state before editing. Preserve session_id, document_id, revision, selection and request_id. Use ocs_read commands with parameters.name for a command manifest. Use ocs_execute batch when several steps are known, and request changed_entities when the resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; +const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path."; const READ_OPS: &[&str] = &[ "state", "hello", "query", + "records", + "record_schema", + "capabilities", "entities", "layers", "header", @@ -38,12 +41,39 @@ const READ_OPS: &[&str] = &[ "operation", ]; const EXECUTE_OPS: &[&str] = &[ - "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", - "property", "action", "save", "stop", "batch", + "new", + "open", + "activate", + "run", + "start", + "input", + "cancel", + "undo", + "redo", + "select", + "property", + "set_properties", + "action", + "save", + "stop", + "batch", ]; const BATCH_STEP_OPS: &[&str] = &[ - "new", "open", "activate", "run", "start", "input", "cancel", "undo", "redo", "select", - "property", "action", "save", "stop", + "new", + "open", + "activate", + "run", + "start", + "input", + "cancel", + "undo", + "redo", + "select", + "property", + "set_properties", + "action", + "save", + "stop", ]; const MAX_BATCH_STEPS: usize = 64; @@ -671,6 +701,15 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { r#"{"op":"property","field":"color","value":1}"#, ) } + "set_properties" + if request["collection"].as_str().is_none_or(str::is_empty) + || request["updates"].as_array().is_none_or(Vec::is_empty) => + { + missing( + "collection or updates", + r#"{"op":"set_properties","collection":"entities","handle":"2A","updates":[{"path":"/common/layer","value":"Walls"}]}"#, + ) + } "action" => match request["name"].as_str() { Some(name) if crate::app::automation_action_names().contains(&name) => Ok(()), Some(name) => Err(format!( @@ -756,7 +795,7 @@ fn call_tool( Ok(Value::Array(sessions(launch)?)) } "ocs_read" => { - let session_id = required_string(arguments, "session_id")?; + let session_id = required_string(arguments, "ocs_session_id")?; let op = arguments["op"].as_str().unwrap_or("state"); if !READ_OPS.contains(&op) { return Err("Use ocs_execute for mutations".into()); @@ -769,7 +808,7 @@ fn call_tool( client(clients, session_id)?.request(Value::Object(request), 30.0) } "ocs_execute" => { - let session_id = required_string(arguments, "session_id")?; + let session_id = required_string(arguments, "ocs_session_id")?; let request = arguments["request"] .as_object() .cloned() @@ -795,7 +834,7 @@ fn call_tool( shape_execute_response(response, detail, gui) } "ocs_capture" => { - let session_id = required_string(arguments, "session_id")?; + let session_id = required_string(arguments, "ocs_session_id")?; let path = std::env::temp_dir().join(format!("ocs-capture-{}.png", random_id()?)); let scope = arguments["scope"].as_str().unwrap_or("viewport"); let max_dimension = arguments["max_dimension"].as_u64().unwrap_or(1600); @@ -833,6 +872,8 @@ fn batch_step_schema() -> Value { "clear":{"type":"boolean"}, "field":{"type":"string"}, "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "collection":{"type":"string"}, + "updates":{"type":"array","minItems":1,"items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{}},"required":["path","value"],"additionalProperties":false}}, "name":{"type":"string","enum":crate::app::automation_action_names()} }, "required":["op"], @@ -873,6 +914,8 @@ fn execute_request_schema() -> Value { "clear":{"type":"boolean","description":"Clear the current selection before applying select filters."}, "field":{"type":"string","minLength":1,"description":"Property id returned by ocs_read properties."}, "value":{"description":"New property value; its JSON type must match the property kind.","anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"object"},{"type":"array"},{"type":"null"}]}, + "collection":{"type":"string","description":"Record collection returned by ocs_read records."}, + "updates":{"type":"array","minItems":1,"description":"Atomic, type-checked property replacements. Paths are RFC 6901 JSON Pointers relative to record.properties.","items":{"type":"object","properties":{"path":{"type":"string","pattern":"^/"},"value":{},"expected":{"description":"Optional compare-and-set value."}},"required":["path","value"],"additionalProperties":false}}, "name":{"type":"string","enum":crate::app::automation_action_names(),"description":"UI action returned by ocs_read commands."}, "steps":{"type":"array","minItems":1,"maxItems":MAX_BATCH_STEPS,"description":"Sequential editor operations executed with fresh state and idempotency keys. Execution stops at the first failure; completed_steps says what committed.","items":batch_step_schema()} }, @@ -898,6 +941,7 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"redo"}}}, {"properties":{"op":{"const":"select"}}}, {"properties":{"op":{"const":"property"}},"required":["field","value"]}, + {"properties":{"op":{"const":"set_properties"}},"required":["collection","updates"]}, {"properties":{"op":{"const":"action"}},"required":["name"]}, {"properties":{"op":{"const":"save"}}}, {"properties":{"op":{"const":"stop"}}}, @@ -943,22 +987,22 @@ fn tool_definitions() -> Value { }, { "name":"ocs_read", - "description":"Read state, command manifests, entities, layers, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name for detailed commands help."},"search":{"type":"string","description":"Case-insensitive command-name search."},"document_id":{"type":"integer","minimum":0},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity handles for query or measure."},"type":{"type":"string","description":"Entity type filter for query."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["session_id"],"additionalProperties":false}, + "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"document_id":{"type":"integer","minimum":0},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_execute", "description":"Execute semantic OCS actions. Use current state fields and a unique request_id. Use run for one complete command, batch to remove round trips, or start plus input for guided steps. accepted, running and waiting_input are not completion.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["session_id","request"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"request":execute_request_schema(),"wait_seconds":{"type":"number","minimum":0,"maximum":60,"default":30,"description":"Total time to wait for completion before returning."},"response_detail":{"type":"string","enum":["compact","changed_entities","full"],"default":"compact","description":"compact returns only state needed for the next edit; changed_entities also returns current geometry for changed handles; full preserves the complete editor state."}},"required":["ocs_session_id","request"],"additionalProperties":false}, "outputSchema":execute_output_schema(), "annotations":{"title":"Execute OCS action","readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false} }, { "name":"ocs_capture", "description":"Capture the actual current OCS drawing viewport or window as a bounded PNG for visual verification.", - "inputSchema":{"type":"object","properties":{"session_id":{"type":"string","minLength":1,"description":"Session returned by ocs_sessions."},"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}},"required":["session_id"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"scope":{"type":"string","enum":["viewport","window"],"default":"viewport","description":"Capture only the drawing viewport by default, or the complete application window."},"max_dimension":{"type":"integer","minimum":256,"maximum":4096,"default":1600,"description":"Resize the longest image edge to at most this many pixels."}},"required":["ocs_session_id"],"additionalProperties":false}, "annotations":{"title":"Capture OCS window","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} } ]) @@ -1271,6 +1315,19 @@ mod tests { ["ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"] ); assert_eq!(tools[0]["annotations"]["readOnlyHint"], false); + for tool in [&tools[1], &tools[2], &tools[3]] { + assert!( + tool["inputSchema"]["properties"] + .get("session_id") + .is_none() + ); + assert!( + tool["inputSchema"]["required"] + .as_array() + .unwrap() + .contains(&json!("ocs_session_id")) + ); + } assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["required"], json!(["op", "request_id"]) @@ -1298,6 +1355,32 @@ mod tests { tools[2]["inputSchema"]["properties"]["request"]["properties"]["cmd"]["examples"][0], "LINE 0,0 10,10" ); + assert!(READ_OPS.contains(&"capabilities")); + assert!(READ_OPS.contains(&"records")); + assert!(READ_OPS.contains(&"record_schema")); + assert!(EXECUTE_OPS.contains(&"set_properties")); + assert_eq!( + tools[1]["inputSchema"]["properties"]["parameters"]["properties"]["where"]["items"]["properties"] + ["op"]["enum"], + json!([ + "eq", + "ne", + "lt", + "lte", + "gt", + "gte", + "contains", + "starts_with", + "ends_with", + "in", + "exists", + "not_exists" + ]) + ); + assert_eq!( + tools[2]["inputSchema"]["properties"]["request"]["properties"]["updates"]["items"]["required"], + json!(["path", "value"]) + ); assert!(tools[0].get("outputSchema").is_some()); assert!(tools[1].get("outputSchema").is_some()); assert!(tools[2].get("outputSchema").is_some()); @@ -1368,7 +1451,7 @@ mod tests { fn read_tool_rejects_mutations_before_connecting() { let mut clients = HashMap::new(); let called = handle_message( - json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"session_id":"missing","op":"save"}}}), + json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ocs_read","arguments":{"ocs_session_id":"missing","op":"save"}}}), &mut clients, &mut TaskStore::default(), ) @@ -1397,7 +1480,7 @@ mod tests { fn execute_requires_a_visible_request_id() { let mut clients = HashMap::new(); let called = handle_message( - json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"undo"}}}}), + json!({"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"ocs_session_id":"missing","request":{"op":"undo"}}}}), &mut clients, &mut TaskStore::default(), ) @@ -1415,7 +1498,7 @@ mod tests { fn execute_errors_explain_missing_operation_fields() { let mut clients = HashMap::new(); let called = handle_message( - json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), + json!({"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ocs_execute","arguments":{"ocs_session_id":"missing","request":{"op":"run","request_id":"run-1"}}}}), &mut clients, &mut TaskStore::default(), ) diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index 80e8f2da1..c1851af62 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -7,7 +7,7 @@ use cadkernel::geom2d::{self, Curve as KernelCurve}; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::IconKind; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; use glam::DVec3; const TAU: f64 = std::f64::consts::TAU; @@ -71,9 +71,9 @@ fn arc_preview( end_angle: f64, plane: WorkingPlane, ) -> Option { - let center = plane.to_local(center); + let center_local = plane.to_local(center); let arc = geom2d::bounded_arc( - [center.x, center.y], + [center_local.x, center_local.y], radius, start_angle, end_angle, @@ -81,14 +81,23 @@ fn arc_preview( let points = KernelCurve::Arc(arc) .tessellate_angle(TAU / 64.0) .into_iter() - .map(|point| plane.to_world(DVec3::new(point[0], point[1], center.z)).to_array()) + .map(|point| plane.to_world(DVec3::new(point[0], point[1], center_local.z)).to_array()) .collect(); - Some(WireModel::solid_f64( + let mut wire = WireModel::solid_f64( "rubber_band".into(), points, WireModel::CYAN, false, - )) + ); + wire.tangent_geoms.push(TangentGeom::Arc { + center: [center.x, center.y, center.z], + axis_x: plane.x.to_array(), + axis_y: plane.y.to_array(), + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + }); + Some(wire) } fn make_arc( diff --git a/src/modules/draw/draw/circle.rs b/src/modules/draw/draw/circle.rs index 12c684bf5..38dc0731f 100644 --- a/src/modules/draw/draw/circle.rs +++ b/src/modules/draw/draw/circle.rs @@ -11,7 +11,7 @@ use crate::t; use crate::command::{CadCommand, CmdResult, DynField, TangentObject, WorkingPlane}; use crate::modules::draw::defaults; use crate::modules::IconKind; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; use glam::DVec3; const TAU: f64 = std::f64::consts::TAU; @@ -56,16 +56,23 @@ pub const ICON: IconKind = ICON_CR; // ── Shared geometry ──────────────────────────────────────────────────────── fn circle_wire(center: DVec3, radius: f64, plane: WorkingPlane) -> WireModel { - let center = plane.to_local(center); + let center_local = plane.to_local(center); let points = KernelCurve::Circle(KernelCircle { - centre: [center.x, center.y], + centre: [center_local.x, center_local.y], radius, }) .tessellate_angle(TAU / 64.0) .into_iter() - .map(|point| plane.to_world(DVec3::new(point[0], point[1], center.z)).to_array()) + .map(|point| plane.to_world(DVec3::new(point[0], point[1], center_local.z)).to_array()) .collect(); - WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false) + let mut wire = WireModel::solid_f64("rubber_band".into(), points, WireModel::CYAN, false); + wire.tangent_geoms.push(TangentGeom::PlanarCircle { + center: [center.x, center.y, center.z], + axis_x: plane.x.to_array(), + axis_y: plane.y.to_array(), + radius, + }); + wire } fn make_circle(center: DVec3, radius: f64, plane: WorkingPlane) -> EntityType { diff --git a/src/modules/draw/draw/ellipse.rs b/src/modules/draw/draw/ellipse.rs index 1b975efde..db8af61c4 100644 --- a/src/modules/draw/draw/ellipse.rs +++ b/src/modules/draw/draw/ellipse.rs @@ -11,7 +11,7 @@ use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::IconKind; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; use glam::DVec3; fn parse_num(text: &str) -> Option { @@ -75,7 +75,16 @@ fn ellipse_wire( [p.x, p.y, p.z] }) .collect(); - WireModel::solid_f64("rubber_band".into(), pts, WireModel::CYAN, false) + let mut wire = WireModel::solid_f64("rubber_band".into(), pts, WireModel::CYAN, false); + wire.tangent_geoms.push(TangentGeom::PlanarEllipse { + center: [center.x, center.y, center.z], + major_axis: [major.x, major.y, major.z], + normal: plane.z.to_array(), + minor_axis_ratio: ratio, + start_param: t_start, + end_param: t_e, + }); + wire } /// Convert a world point to the parametric angle on the ellipse. diff --git a/src/modules/draw/draw/polyline.rs b/src/modules/draw/draw/polyline.rs index 61542c690..852c42400 100644 --- a/src/modules/draw/draw/polyline.rs +++ b/src/modules/draw/draw/polyline.rs @@ -17,7 +17,7 @@ use crate::t; use crate::command::{CadCommand, CmdResult, WorkingPlane}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; -use crate::scene::model::wire_model::WireModel; +use crate::scene::model::wire_model::{TangentGeom, WireModel}; // ── Ribbon definition ────────────────────────────────────────────────────── @@ -443,6 +443,7 @@ impl CadCommand for PlineCommand { let pt = self.plane.to_local(pt_world).as_vec3(); let mut pts: Vec<[f32; 3]> = Vec::new(); + let mut tangent_geom = None; match self.mode { SegMode::Line => { pts.push([last.x, last.y, last.z]); @@ -456,8 +457,44 @@ impl CadCommand for PlineCommand { .map(|t| t.as_dvec2()) .unwrap_or(DVec2::new(1.0, 0.0)); let bulge = compute_bulge(a, tangent, b); - let arc_pts = arc_sample_points(last, bulge, pt, 16); - pts.extend_from_slice(&arc_pts); + if bulge.abs() >= 1e-9 && (b - a).length_squared() >= 1e-12 { + if let Some(arc) = + crate::entities::common::BulgeArc::from_bulge([a.x, a.y], [b.x, b.y], bulge) + { + for s in arc.tessellate_angle(std::f64::consts::TAU / 64.0) { + pts.push([s[0] as f32, s[1] as f32, last.z]); + } + let (sa, ea) = if arc.sweep >= 0.0 { + (arc.start_angle, arc.end_angle) + } else { + (arc.end_angle, arc.start_angle) + }; + let world_center = self + .plane + .to_world(DVec3::new(arc.center[0], arc.center[1], last.z as f64)); + if arc.radius > 0.0 + && arc.radius.is_finite() + && arc.radius <= 1e6 + && sa.is_finite() + && ea.is_finite() + { + tangent_geom = Some(TangentGeom::Arc { + center: world_center.to_array(), + axis_x: self.plane.x.to_array(), + axis_y: self.plane.y.to_array(), + radius: arc.radius, + start_angle: sa, + end_angle: ea, + }); + } + } else { + let arc_pts = arc_sample_points(last, bulge, pt, 64); + pts.extend_from_slice(&arc_pts); + } + } else { + pts.push([last.x, last.y, last.z]); + pts.push([pt.x, pt.y, pt.z]); + } } } @@ -465,12 +502,16 @@ impl CadCommand for PlineCommand { .into_iter() .map(|point| self.plane.to_world(Vec3::from_array(point).as_dvec3()).as_vec3().to_array()) .collect(); - Some(WireModel::solid( + let mut wire = WireModel::solid( "rubber_band".into(), world_points, WireModel::CYAN, false, - )) + ); + if let Some(tg) = tangent_geom { + wire.tangent_geoms.push(tg); + } + Some(wire) } } @@ -548,4 +589,20 @@ mod tests { CmdResult::FinalizeLiveEntity(h) if h == handle )); } + + #[test] + fn test_draw_line_then_arc() { + let mut cmd = PlineCommand::new(); + cmd.on_point(DVec3::new(0.0, 0.0, 0.0)); + let res = cmd.on_point(DVec3::new(10.0, 0.0, 0.0)); + assert!(matches!(res, CmdResult::CommitLiveEntity(_))); + cmd.set_live_handle(Handle::new(1)); + cmd.on_text_input("A"); + let wire = cmd.on_mouse_move(DVec3::new(10.0, 5.0, 0.0)).expect("arc wire"); + assert_eq!(wire.tangent_geoms.len(), 1); + assert!(matches!(wire.tangent_geoms[0], TangentGeom::Arc { .. })); + assert!(wire.points.len() >= 16); + let res2 = cmd.on_point(DVec3::new(10.0, 5.0, 0.0)); + assert!(matches!(res2, CmdResult::UpdateLiveEntity { .. })); + } } diff --git a/src/modules/draw/modify/fillet.rs b/src/modules/draw/modify/fillet.rs index 450c8b48b..e23f3635a 100644 --- a/src/modules/draw/modify/fillet.rs +++ b/src/modules/draw/modify/fillet.rs @@ -77,7 +77,122 @@ fn project_click(click: [f64; 2], p1: [f64; 2], unit: [f64; 2]) -> f64 { } // ── Fillet ───────────────────────────────────────────────────────────────── +/// Fillet two parallel lines with a semicircle tangent to both. +/// +/// The current FILLET radius is intentionally ignored for parallel lines: +/// the effective radius is half the perpendicular distance between them. +/// +/// The endpoint of the first line nearest its pick determines where the +/// semicircle is placed. The second line is trimmed or extended to the +/// corresponding perpendicular tangent point. +fn fillet_parallel_lines( + l1: &LineEnt, + click1: [f64; 2], + l2: &LineEnt, + click2: [f64; 2], +) -> Option<(EntityType, EntityType, Option)> { + let (p1, p2, u1, len1) = line_geom(l1); + let (p3, _p4, u2, len2) = line_geom(l2); + if len1 <= 1.0e-12 || len2 <= 1.0e-12 { + return None; + } + + // This helper is only for parallel / anti-parallel lines. + let cross = u1[0] * u2[1] - u1[1] * u2[0]; + if cross.abs() > 1.0e-6 { + return None; + } + + // The pick on the first line determines which end receives the round. + let dist_sq = |a: [f64; 2], b: [f64; 2]| { + let dx = a[0] - b[0]; + let dy = a[1] - b[1]; + dx * dx + dy * dy + }; + + let (tangent1, other1) = if dist_sq(click1, p1) <= dist_sq(click1, p2) { + (p1, p2) + } else { + (p2, p1) + }; + + // Unit normal to line 1. Project tangent1 perpendicularly onto line 2. + let normal = [-u1[1], u1[0]]; + let separation = + (p3[0] - tangent1[0]) * normal[0] + (p3[1] - tangent1[1]) * normal[1]; + + if separation.abs() <= 1.0e-9 { + // Coincident lines do not define a useful semicircle. + return None; + } + + let tangent2 = [ + tangent1[0] + normal[0] * separation, + tangent1[1] + normal[1] * separation, + ]; + + let centre = [ + (tangent1[0] + tangent2[0]) * 0.5, + (tangent1[1] + tangent2[1]) * 0.5, + ]; + + let radius = separation.abs() * 0.5; + + // Keep the existing part of each selected line and trim/extend the picked + // end to its tangent point. + let new_l1 = trim_line_to_point(l1, tangent1, click1)?; + let new_l2 = trim_line_to_point(l2, tangent2, click2)?; + + // The semicircle must bulge away from the retained portion of line 1. + // `other1 - tangent1` points back into the line, so negate it. + let keep = [ + other1[0] - tangent1[0], + other1[1] - tangent1[1], + ]; + let keep_len = (keep[0] * keep[0] + keep[1] * keep[1]).sqrt(); + + if keep_len <= 1.0e-12 { + return None; + } + + let bulge_dir = [-keep[0] / keep_len, -keep[1] / keep_len]; + + let a1 = norm_angle( + (tangent1[1] - centre[1]).atan2(tangent1[0] - centre[0]), + ); + let a2 = norm_angle( + (tangent2[1] - centre[1]).atan2(tangent2[0] - centre[0]), + ); + + // Between two antipodal points there are two possible semicircles. + // Determine which CCW orientation bulges toward the selected end. + let midpoint_angle = a1 + std::f64::consts::FRAC_PI_2; + let midpoint_dir = [midpoint_angle.cos(), midpoint_angle.sin()]; + + let candidate_matches = + midpoint_dir[0] * bulge_dir[0] + midpoint_dir[1] * bulge_dir[1] >= 0.0; + + let (start_angle, end_angle) = if candidate_matches { + (a1, a2) + } else { + (a2, a1) + }; + + let mut arc = ArcEnt::new(); + arc.common = l1.common.clone(); + arc.common.handle = Handle::NULL; + arc.center = Vector3::new(centre[0], centre[1], l1.start.z); + arc.radius = radius; + arc.start_angle = start_angle; + arc.end_angle = end_angle; + + Some(( + EntityType::Line(new_l1), + EntityType::Line(new_l2), + Some(EntityType::Arc(arc)), + )) +} /// Compute fillet: trim l1/l2 and insert a tangent arc of `radius`. /// Returns (trimmed_l1, trimmed_l2, fillet_arc). fn compute_fillet( @@ -90,8 +205,18 @@ fn compute_fillet( let (p1, p2, u1, _len1) = line_geom(l1); let (p3, p4, u2, _len2) = line_geom(l2); - // Intersection of infinite lines - let (t_p, _u_p) = ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?; + // Parallel lines are a special FILLET case: there is no intersection. + // Build the tangent semicircle directly, temporarily ignoring the + // configured radius. + let cross = u1[0] * u2[1] - u1[1] * u2[0]; + + if cross.abs() <= 1.0e-6 { + return fillet_parallel_lines(l1, click1, l2, click2); + } + + // Intersection of infinite non-parallel lines. + let (t_p, _u_p) = + ll(p1[0], p1[1], u1[0], u1[1], p3[0], p3[1], u2[0], u2[1])?; // Intersection point let px = p1[0] + t_p * u1[0]; @@ -123,14 +248,6 @@ fn compute_fillet( [-u2[0], -u2[1]] }; - // Angle between the two keep-directions - let cos_a = (dir1[0] * dir2[0] + dir1[1] * dir2[1]).clamp(-1.0, 1.0); - let angle = cos_a.acos(); - - // Lines are parallel / anti-parallel - if angle < 1e-6 || (angle - std::f64::consts::PI).abs() < 1e-6 { - return None; - } let z = l1.start.z; @@ -337,7 +454,194 @@ fn compute_bulge(t1: [f64; 2], t2: [f64; 2], arc_center: [f64; 2]) -> f64 { -(span_cw / 4.0).tan() // CW arc } } +#[derive(Clone, Copy)] +struct PolylineCornerFillet { + incoming: [f64; 2], + outgoing: [f64; 2], + bulge: f64, +} +fn segment_parameter(point: [f64; 2], start: [f64; 2], end: [f64; 2]) -> Option { + let dx = end[0] - start[0]; + let dy = end[1] - start[1]; + let len2 = dx * dx + dy * dy; + + if len2 <= 1.0e-20 { + return None; + } + + Some( + ((point[0] - start[0]) * dx + (point[1] - start[1]) * dy) / len2, + ) +} + +/// Apply the current FILLET radius to every eligible corner of a 2D polyline. +/// +/// Initial scope deliberately handles straight-segment corners only. Corners +/// adjacent to an existing bulge arc are preserved unchanged. +fn fillet_entire_lwpolyline(poly: &LwPolyline, radius: f64) -> Option { + let n = poly.vertices.len(); + + if n < 3 || radius <= 1.0e-9 { + return None; + } + + let mut corners: Vec> = vec![None; n]; + + let first_corner = if poly.is_closed { 0 } else { 1 }; + let end_corner = if poly.is_closed { + n + } else { + n.saturating_sub(1) + }; + + for i in first_corner..end_corner { + let prev = if i == 0 { n - 1 } else { i - 1 }; + + // A bulge belongs to the segment starting at that vertex. + // For now only fillet corners where both adjacent segments are straight. + if poly.vertices[prev].bulge.abs() >= 1.0e-9 + || poly.vertices[i].bulge.abs() >= 1.0e-9 + { + continue; + } + + let incoming = lwpoly_seg_as_line(poly, prev); + let outgoing = lwpoly_seg_as_line(poly, i); + + // Pick points halfway along each segment, away from the corner. + // This tells the existing line-line FILLET code which side to keep. + let click_in = [ + (incoming.start.x + incoming.end.x) * 0.5, + (incoming.start.y + incoming.end.y) * 0.5, + ]; + let click_out = [ + (outgoing.start.x + outgoing.end.x) * 0.5, + (outgoing.start.y + outgoing.end.y) * 0.5, + ]; + + let Some((trimmed_in, trimmed_out, maybe_arc)) = + compute_fillet(&incoming, click_in, &outgoing, click_out, radius) + else { + continue; + }; + + let ( + EntityType::Line(trimmed_in), + EntityType::Line(trimmed_out), + Some(EntityType::Arc(arc)), + ) = (trimmed_in, trimmed_out, maybe_arc) + else { + continue; + }; + + let tangent_in = [trimmed_in.end.x, trimmed_in.end.y]; + let tangent_out = [trimmed_out.start.x, trimmed_out.start.y]; + + let in_start = [incoming.start.x, incoming.start.y]; + let in_end = [incoming.end.x, incoming.end.y]; + let out_start = [outgoing.start.x, outgoing.start.y]; + let out_end = [outgoing.end.x, outgoing.end.y]; + + let Some(t_in) = segment_parameter(tangent_in, in_start, in_end) else { + continue; + }; + let Some(t_out) = segment_parameter(tangent_out, out_start, out_end) else { + continue; + }; + + // Whole-polyline FILLET should not extend past neighbouring vertices. + // If this radius cannot fit at this corner, leave that corner unchanged. + if !(-1.0e-9..=1.0 + 1.0e-9).contains(&t_in) + || !(-1.0e-9..=1.0 + 1.0e-9).contains(&t_out) + { + continue; + } + + corners[i] = Some(PolylineCornerFillet { + incoming: tangent_in, + outgoing: tangent_out, + bulge: compute_bulge( + tangent_in, + tangent_out, + [arc.center.x, arc.center.y], + ), + }); + } + + if corners.iter().all(Option::is_none) { + return None; + } + + // Adjacent fillets must not consume more than the shared segment. + // Reject the operation rather than producing an inverted/overlapping edge. + let segment_count = if poly.is_closed { n } else { n - 1 }; + + for i in 0..segment_count { + let next = (i + 1) % n; + + let start = corners[i] + .map(|corner| corner.outgoing) + .unwrap_or([ + poly.vertices[i].location.x, + poly.vertices[i].location.y, + ]); + + let end = corners[next] + .map(|corner| corner.incoming) + .unwrap_or([ + poly.vertices[next].location.x, + poly.vertices[next].location.y, + ]); + + let original_start = [ + poly.vertices[i].location.x, + poly.vertices[i].location.y, + ]; + let original_end = [ + poly.vertices[next].location.x, + poly.vertices[next].location.y, + ]; + + let Some(start_t) = segment_parameter(start, original_start, original_end) else { + continue; + }; + let Some(end_t) = segment_parameter(end, original_start, original_end) else { + continue; + }; + + if start_t > end_t + 1.0e-9 { + return None; + } + } + + let mut result = poly.clone(); + result.common.handle = Handle::NULL; + result.vertices.clear(); + + for (i, original) in poly.vertices.iter().enumerate() { + if let Some(corner) = corners[i] { + let mut tangent_in = original.clone(); + tangent_in.location.x = corner.incoming[0]; + tangent_in.location.y = corner.incoming[1]; + tangent_in.bulge = corner.bulge; + + let mut tangent_out = original.clone(); + tangent_out.location.x = corner.outgoing[0]; + tangent_out.location.y = corner.outgoing[1]; + + // This corner is only eligible when its outgoing segment is straight. + tangent_out.bulge = 0.0; + + result.vertices.push(tangent_in); + result.vertices.push(tangent_out); + } else { + result.vertices.push(original.clone()); + } + } + + Some(result) +} /// Rebuild a LwPolyline, replacing the corner vertex at `corner_idx` with two /// new vertices T1 (start of fillet arc) and T2 (end of fillet arc). /// `bulge` encodes the direction and span of the arc (T1 → T2). @@ -991,6 +1295,7 @@ fn compute_chamfer( enum FilletStep { First, + Polyline, WaitingForRadius, Second { h1: Handle, @@ -1072,7 +1377,12 @@ impl CadCommand for FilletCommand { fn prompt(&self) -> String { match &self.step { FilletStep::First => crate::tf!( - "FILLET Select first object (Line/Arc/LwPolyline) [R={:.4}]:", + "FILLET Select first object or [Polyline/Radius] [R={:.4}]:", + self.radius + ) + .into_owned(), + FilletStep::Polyline => crate::tf!( + "FILLET Select 2D polyline [R={:.4}]:", self.radius ) .into_owned(), @@ -1091,11 +1401,16 @@ impl CadCommand for FilletCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + match self.step { - // Offer the radius keyword while selecting objects; the "R" token is - // already handled by on_text_input. - FilletStep::First | FilletStep::Second { .. } => vec![CmdOption::new("Radius", "R")], - FilletStep::WaitingForRadius => vec![], + FilletStep::First => vec![ + CmdOption::new("Polyline", "P"), + CmdOption::new("Radius", "R"), + ], + FilletStep::Second { .. } => { + vec![CmdOption::new("Radius", "R")] + } + FilletStep::Polyline | FilletStep::WaitingForRadius => vec![], } } @@ -1134,6 +1449,10 @@ impl CadCommand for FilletCommand { FilletStep::First | FilletStep::Second { .. } => { let t = text.trim(); let upper = t.to_uppercase(); + if matches!(self.step, FilletStep::First) && upper == "P" { + self.step = FilletStep::Polyline; + return Some(CmdResult::NeedPoint); + } // "R" alone → enter sub-step to collect radius if upper == "R" { self.enter_radius_substep(); @@ -1156,6 +1475,7 @@ impl CadCommand for FilletCommand { } None } + FilletStep::Polyline => None, } } @@ -1171,6 +1491,28 @@ impl CadCommand for FilletCommand { match &self.step { FilletStep::WaitingForRadius => return CmdResult::NeedPoint, + FilletStep::Polyline => { + let poly = self + .entity_index + .get(&self.all_entities, handle) + .and_then(|entity| match entity { + EntityType::LwPolyline(poly) => Some(poly.clone()), + _ => None, + }); + + let Some(poly) = poly else { + return CmdResult::NeedPoint; + }; + + let Some(result) = fillet_entire_lwpolyline(&poly, self.radius) else { + return CmdResult::NeedPoint; + }; + + self.continue_after_fillet(vec![( + handle, + vec![EntityType::LwPolyline(result)], + )]) + } FilletStep::First => { let e1 = self .entity_index @@ -1245,6 +1587,28 @@ impl CadCommand for FilletCommand { match &self.step { FilletStep::WaitingForRadius => vec![], + FilletStep::Polyline => { + let preview = self + .entity_index + .get(&self.all_entities, handle) + .and_then(|entity| match entity { + EntityType::LwPolyline(poly) => { + fillet_entire_lwpolyline(poly, self.radius) + } + _ => None, + }); + + preview + .map(|poly| { + vec![WireModel::solid( + "fillet_polyline_preview".into(), + lwpoly_pts(&poly), + WireModel::CYAN, + false, + )] + }) + .unwrap_or_default() + } FilletStep::First => { let pts = self .entity_index @@ -1515,12 +1879,13 @@ impl CadCommand for ChamferCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + match self.step { - // Offer the distance keyword while selecting objects; the "D" token - // is already handled by on_text_input. ChamferStep::First | ChamferStep::Second { .. } - | ChamferStep::SecondPoly { .. } => vec![CmdOption::new(t!("Distance").as_ref(), "D")], + | ChamferStep::SecondPoly { .. } => { + vec![CmdOption::new(t!("Distance").as_ref(), "D")] + } ChamferStep::WaitingForDist1 | ChamferStep::WaitingForDist2 => vec![], } } diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 9ff9efe08..8116fbc07 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -17,6 +17,7 @@ use crate::modules::draw::fence::FencePick; use crate::scene::model::wire_model::WireModel; pub struct SelectObjectsCommand { + prompt_cmd: String, pending_cmd: String, /// Selection accumulated so far (kept in sync with the scene selection by /// each `on_selection_complete` call). Applied on Enter when `commit_on_enter`. @@ -24,6 +25,8 @@ pub struct SelectObjectsCommand { /// When true, gathering continues until Enter / right-click commits the set. /// When false, the first completed selection action fires immediately. commit_on_enter: bool, + /// Whether the generic selection methods are shown as command buttons. + show_options: bool, /// A Fence / WPolygon / CPolygon path being picked point by point. While /// one is under way the host must send clicks here as points rather than /// treating them as picks, which is what `is_selection_gathering` reports. @@ -37,9 +40,25 @@ impl SelectObjectsCommand { /// Standard selection set: accumulate picks, apply on Enter / right-click. pub fn new(pending_cmd: &str) -> Self { Self { + prompt_cmd: pending_cmd.to_string(), pending_cmd: pending_cmd.to_string(), handles: Vec::new(), commit_on_enter: true, + show_options: true, + pick: None, + pick_crossing: true, + } + } + + /// Plain gather prompt with no generic selection-method buttons. The + /// visible command name may differ from the private apply command. + pub fn plain(prompt_cmd: &str, pending_cmd: &str) -> Self { + Self { + prompt_cmd: prompt_cmd.to_string(), + pending_cmd: pending_cmd.to_string(), + handles: Vec::new(), + commit_on_enter: true, + show_options: false, pick: None, pick_crossing: true, } @@ -49,9 +68,11 @@ impl SelectObjectsCommand { /// immediately, with no Enter (used by commands that act on one object). pub fn instant(pending_cmd: &str) -> Self { Self { + prompt_cmd: pending_cmd.to_string(), pending_cmd: pending_cmd.to_string(), handles: Vec::new(), commit_on_enter: false, + show_options: false, pick: None, pick_crossing: true, } @@ -82,12 +103,12 @@ impl CadCommand for SelectObjectsCommand { if self.commit_on_enter && !self.handles.is_empty() { t!( "%{cmd} Select objects (%{count} selected, Enter to apply):", - cmd = self.pending_cmd, + cmd = self.prompt_cmd, count = self.handles.len() ) .into_owned() } else { - t!("%{cmd} Select objects:", cmd = self.pending_cmd).into_owned() + t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned() } } @@ -108,7 +129,7 @@ impl CadCommand for SelectObjectsCommand { // they work typed as well — these buttons only surface them, which is what // the on-screen keyboard-less case needs. fn options(&self) -> Vec { - if self.commit_on_enter { + if self.commit_on_enter && self.show_options { vec![ CmdOption::new(t!("Window").as_ref(), "W"), CmdOption::new(t!("Crossing").as_ref(), "C"), diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index f068bc8c4..e5cfce0fb 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -2211,7 +2211,7 @@ impl LoftCommand { fn option_prompt(&self) -> String { match self.state.step { LoftStep::Sections => format!( - "{} ({}):", t!("LOFT Select cross-sections in order or [Point/Join/Mode/Undo] (Enter to finish)"), + "{} ({}):", t!("LOFT Select cross-sections in order or [Point/Join/Mode] (Enter to finish)"), self.state.sections.len(), ), LoftStep::Join => format!( @@ -2224,9 +2224,9 @@ impl LoftCommand { LoftStep::Options => { let (start, end) = self.point_ends(); if start || end { - t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude/Mode/Undo] :").into_owned() + t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Continuity/Bulge magnitude] :").into_owned() } else { - t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings/Mode/Undo] :").into_owned() + t!("LOFT Enter an option [Guides/Path/Cross sections only/Settings] :").into_owned() } } LoftStep::Guides => format!("{} ({}):", t!("LOFT Select guide curves or [Undo] (Enter to finish)"), self.state.guides.len()), @@ -2272,7 +2272,7 @@ impl CadCommand for LoftCommand { } fn options(&self) -> Vec { let mut options = match self.state.step { - LoftStep::Sections => vec![CmdOption::new("Point", "POINT"), CmdOption::new("Join", "JOIN"), CmdOption::new("Mode", "MODE"), CmdOption::enter("Done")], + LoftStep::Sections => vec![CmdOption::new("Point", "POINT"), CmdOption::new("Join", "JOIN"), CmdOption::new("Mode", "MODE")], LoftStep::Join | LoftStep::Guides => vec![CmdOption::enter("Done")], LoftStep::Mode => vec![CmdOption::new("Solid", "SOLID"), CmdOption::new("Surface", "SURFACE")], LoftStep::Options => { @@ -2282,7 +2282,6 @@ impl CadCommand for LoftCommand { choices.push(CmdOption::new("Continuity", "CONTINUITY")); choices.push(CmdOption::new("Bulge magnitude", "BULGE")); } - choices.push(CmdOption::new("Mode", "MODE")); choices } LoftStep::Settings => { @@ -2308,7 +2307,9 @@ impl CadCommand for LoftCommand { LoftStep::StartContinuity | LoftStep::EndContinuity => vec![CmdOption::new("G0", "G0"), CmdOption::new("G1", "G1")], _ => Vec::new(), }; - options.push(CmdOption::new("Undo", "UNDO")); + if !matches!(self.state.step, LoftStep::Sections | LoftStep::Options) { + options.push(CmdOption::new("Undo", "UNDO")); + } options } fn needs_entity_pick(&self) -> bool { diff --git a/src/modules/model/boolean_cmd.rs b/src/modules/model/boolean_cmd.rs index ff607b815..d24567b3c 100644 --- a/src/modules/model/boolean_cmd.rs +++ b/src/modules/model/boolean_cmd.rs @@ -25,16 +25,20 @@ impl BoolOp { enum SubtractStep { Bases, Cutters, + ConvertMeshes, } pub struct SubtractCommand { step: SubtractStep, bases: Vec, + cutters: Vec, selected: Vec, + bases_have_mesh: bool, + selected_has_mesh: bool, } impl SubtractCommand { - pub fn new(bases: Vec) -> Self { + pub fn new(bases: Vec, bases_have_mesh: bool) -> Self { let step = if bases.is_empty() { SubtractStep::Bases } else { @@ -43,7 +47,18 @@ impl SubtractCommand { Self { step, bases, + cutters: Vec::new(), selected: Vec::new(), + bases_have_mesh, + selected_has_mesh: false, + } + } + + fn finish(&mut self, convert_meshes: bool) -> CmdResult { + CmdResult::SolidSubtract { + bases: std::mem::take(&mut self.bases), + cutters: std::mem::take(&mut self.cutters), + convert_meshes, } } } @@ -55,11 +70,26 @@ impl CadCommand for SubtractCommand { fn prompt(&self) -> String { match self.step { - SubtractStep::Bases => crate::t!("SUBTRACT Select base solids, then press Enter:") + SubtractStep::Bases => crate::t!("SUBTRACT Select base Solids, Regions, Surfaces, or Meshes, then press Enter:") .into_owned(), SubtractStep::Cutters => { - crate::t!("SUBTRACT Select solids to subtract, then press Enter:").into_owned() + crate::t!("SUBTRACT Select Solids, Regions, Surfaces, or Meshes to subtract, then press Enter:").into_owned() } + SubtractStep::ConvertMeshes => crate::t!( + "SUBTRACT Convert selected closed Mesh objects to solids? [Yes/No] :" + ) + .into_owned(), + } + } + + fn options(&self) -> Vec { + if matches!(self.step, SubtractStep::ConvertMeshes) { + vec![ + crate::command::CmdOption::new(crate::t!("Yes").as_ref(), "Y"), + crate::command::CmdOption::new(crate::t!("No").as_ref(), "N"), + ] + } else { + Vec::new() } } @@ -68,31 +98,72 @@ impl CadCommand for SubtractCommand { } fn on_enter(&mut self) -> CmdResult { + if matches!(self.step, SubtractStep::ConvertMeshes) { + return self.finish(true); + } if self.selected.is_empty() { return CmdResult::NeedPoint; } match self.step { SubtractStep::Bases => { self.bases = std::mem::take(&mut self.selected); + self.bases_have_mesh = self.selected_has_mesh; + self.selected_has_mesh = false; self.step = SubtractStep::Cutters; CmdResult::DeselectAndContinue } - SubtractStep::Cutters => CmdResult::SolidSubtract { - bases: std::mem::take(&mut self.bases), - cutters: std::mem::take(&mut self.selected), - }, + SubtractStep::Cutters => { + self.cutters = std::mem::take(&mut self.selected); + if self.bases_have_mesh || self.selected_has_mesh { + self.step = SubtractStep::ConvertMeshes; + CmdResult::NeedPoint + } else { + self.finish(false) + } + } + SubtractStep::ConvertMeshes => unreachable!(), } } + fn wants_text_input(&self) -> bool { + matches!(self.step, SubtractStep::ConvertMeshes) + } + + fn on_text_input(&mut self, text: &str) -> Option { + if !matches!(self.step, SubtractStep::ConvertMeshes) { + return None; + } + Some(match text.trim().to_ascii_lowercase().as_str() { + "" | "y" | "yes" => self.finish(true), + "n" | "no" => self.finish(false), + _ => CmdResult::NeedPoint, + }) + } + fn is_selection_gathering(&self) -> bool { - true + !matches!(self.step, SubtractStep::ConvertMeshes) } fn inject_selection_entities(&mut self, entities: Vec) { + self.selected_has_mesh = entities.iter().any(|entity| { + matches!( + entity.entity, + EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) + ) + }); self.selected = entities .into_iter() .filter_map(|entity| { - matches!(entity.entity, EntityType::Solid3D(_)).then_some(entity.handle) + matches!( + entity.entity, + EntityType::Solid3D(_) + | EntityType::Region(_) + | EntityType::Surface(_) + | EntityType::Mesh(_) + | EntityType::PolygonMesh(_) + | EntityType::PolyfaceMesh(_) + ) + .then_some(entity.handle) }) .collect(); } diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index 65ee2d315..ae7cb1303 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -1,42 +1,311 @@ +use std::sync::atomic::{AtomicU64, Ordering}; + use acadrust::Handle; +use cadkernel::brep::{Body, EdgeKey, FaceKey}; use glam::DVec3; use crate::command::{ - CadCommand, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, + CadCommand, CmdOption, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec, }; +use crate::scene::model::wire_model::WireModel; -#[derive(Clone, Copy)] +static FILLET_RADIUS_BITS: AtomicU64 = AtomicU64::new(1.0f64.to_bits()); + +fn fillet_radius() -> f64 { + f64::from_bits(FILLET_RADIUS_BITS.load(Ordering::Relaxed)) +} + +fn set_fillet_radius(value: f64) { + FILLET_RADIUS_BITS.store(value.to_bits(), Ordering::Relaxed); +} + +#[derive(Clone, Copy, PartialEq, Eq)] pub enum EdgeOperation { Fillet, Chamfer, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeSelectionMode { + Edge, + Chain, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum EdgeStep { + Selecting, + PickingLoop, + LoopConfirm, + PreviewConfirm, + Radius, + Expression, + ChamferDistance1, + ChamferDistance2, +} + pub struct SolidEdgeCommand { operation: EdgeOperation, target: Option, - picked: Option<(Handle, DVec3)>, + bodies: Vec<(Handle, Body)>, + handle: Option, + selected_edges: Vec, + base_face: Option, + selection_batches: Vec<(Vec, DVec3)>, + selection_mode: EdgeSelectionMode, + step: EdgeStep, + value_return: EdgeStep, + expression_return: EdgeStep, + loop_candidates: Vec<(FaceKey, Vec)>, + loop_index: usize, + last_pick: Option, default_value: f64, + other_value: f64, + preview_color: [f32; 4], + preview_wires: Vec, + preview_hidden: Vec, } impl SolidEdgeCommand { - pub fn new(operation: EdgeOperation, target: Option) -> Self { + pub fn new( + operation: EdgeOperation, + target: Option, + bodies: Vec<(Handle, Body)>, + preview_color: [f32; 4], + ) -> Self { Self { operation, target, - picked: None, - default_value: 1.0, + bodies, + handle: None, + selected_edges: Vec::new(), + base_face: None, + selection_batches: Vec::new(), + selection_mode: EdgeSelectionMode::Edge, + step: EdgeStep::Selecting, + value_return: EdgeStep::Selecting, + expression_return: EdgeStep::Radius, + loop_candidates: Vec::new(), + loop_index: 0, + last_pick: None, + default_value: if operation == EdgeOperation::Fillet { + fillet_radius() + } else { + 1.0 + }, + other_value: 1.0, + preview_color, + preview_wires: Vec::new(), + preview_hidden: Vec::new(), } } - fn finish(&self, value: f64) -> CmdResult { - let Some((handle, pick)) = self.picked else { + pub fn new_chamfer( + target: Option, + bodies: Vec<(Handle, Body)>, + preview_color: [f32; 4], + distances: (f64, f64), + ) -> Self { + let mut command = Self::new(EdgeOperation::Chamfer, target, bodies, preview_color); + command.default_value = positive_default(distances.0); + command.other_value = positive_default(distances.1); + command + } + + pub fn chamfer_distances(&self) -> (f64, f64) { + (self.default_value, self.other_value) + } + + fn body(&self, handle: Handle) -> Option<&Body> { + self.bodies + .iter() + .find_map(|(candidate, body)| (*candidate == handle).then_some(body)) + } + + fn active_body(&self) -> Option<(Handle, &Body)> { + let handle = self.handle?; + self.body(handle).map(|body| (handle, body)) + } + + fn pick_allowed(&self, handle: Handle) -> bool { + !handle.is_null() + && self.target.is_none_or(|target| target == handle) + && self.handle.is_none_or(|selected| selected == handle) + && self.body(handle).is_some() + } + + fn add_batch(&mut self, edges: Vec, anchor: DVec3) -> bool { + let batch = edges + .into_iter() + .filter(|edge| !self.selected_edges.contains(edge)) + .collect::>(); + if batch.is_empty() { + return false; + } + self.selected_edges.extend(batch.iter().copied()); + self.selection_batches.push((batch, anchor)); + true + } + + fn current_loop(&self) -> Option<&[EdgeKey]> { + self.loop_candidates + .get(self.loop_index) + .map(|(_, edges)| edges.as_slice()) + } + + fn current_loop_face(&self) -> Option { + self.loop_candidates + .get(self.loop_index) + .map(|(face, _)| *face) + } + + fn preview_edges(&self) -> Vec { + let mut edges = self.selected_edges.clone(); + if self.step == EdgeStep::LoopConfirm { + if let Some(loop_edges) = self.current_loop() { + for edge in loop_edges { + if !edges.contains(edge) { + edges.push(*edge); + } + } + } + } + edges + } + + fn preview_active(&self) -> bool { + matches!( + self.step, + EdgeStep::Selecting + | EdgeStep::PickingLoop + | EdgeStep::LoopConfirm + | EdgeStep::PreviewConfirm + | EdgeStep::Radius + | EdgeStep::Expression + | EdgeStep::ChamferDistance1 + | EdgeStep::ChamferDistance2 + ) + } + + fn preview_for_values( + &self, + value: f64, + other_value: f64, + ) -> Option<(Handle, Vec)> { + if !self.preview_active() { + return None; + } + let edges = self.preview_edges(); + if edges.is_empty() { + return None; + } + let (handle, body) = self.active_body()?; + let result = match self.operation { + EdgeOperation::Fillet => cadkernel::brep::fillet_edges(body, &edges, value).ok()?, + EdgeOperation::Chamfer => cadkernel::brep::chamfer_edges( + body, + &edges, + self.base_face.or_else(|| self.current_loop_face())?, + value, + other_value, + ) + .ok()?, + }; + let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); + for wire in &mut wires { + wire.color = self.preview_color; + wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name()); + } + (!wires.is_empty()).then_some((handle, wires)) + } + + fn rebuild_preview(&mut self) { + self.preview_wires.clear(); + self.preview_hidden.clear(); + if let Some((handle, wires)) = + self.preview_for_values(self.default_value, self.other_value) + { + self.preview_wires = wires; + self.preview_hidden.push(handle); + } + } + + fn begin_radius(&mut self, return_to: EdgeStep) -> CmdResult { + self.value_return = return_to; + self.step = EdgeStep::Radius; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn accept_radius(&mut self, value: f64) -> Option { + if value <= 0.0 || !value.is_finite() { + return None; + } + self.default_value = value; + if self.operation == EdgeOperation::Fillet { + set_fillet_radius(value); + } + self.step = self.value_return; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + + fn begin_distances(&mut self, return_to: EdgeStep) -> CmdResult { + self.value_return = return_to; + self.step = EdgeStep::ChamferDistance1; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn accept_distance(&mut self, value: f64) -> Option { + if value <= 0.0 || !value.is_finite() { + return None; + } + match self.step { + EdgeStep::ChamferDistance1 => { + self.default_value = value; + self.step = EdgeStep::ChamferDistance2; + } + EdgeStep::ChamferDistance2 => { + self.other_value = value; + self.step = self.value_return; + } + _ => return None, + } + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + + fn accept_loop(&mut self) -> CmdResult { + if let (Some(edges), Some(anchor)) = ( + self.current_loop().map(|edges| edges.to_vec()), + self.last_pick, + ) { + if self.operation == EdgeOperation::Chamfer && self.base_face.is_none() { + self.base_face = self.current_loop_face(); + } + self.add_batch(edges, anchor); + } + self.loop_candidates.clear(); + self.loop_index = 0; + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + CmdResult::NeedPoint + } + + fn finish(&self) -> CmdResult { + let Some(handle) = self.handle else { return CmdResult::Cancel; }; + if self.selected_edges.is_empty() { + return CmdResult::Cancel; + } CmdResult::SolidEdgeBlend { handle, - pick, - value, - fillet: matches!(self.operation, EdgeOperation::Fillet), + edges: self.selected_edges.clone(), + base_face: self.base_face, + value: self.default_value, + other_value: self.other_value, + fillet: self.operation == EdgeOperation::Fillet, } } } @@ -44,27 +313,114 @@ impl SolidEdgeCommand { impl CadCommand for SolidEdgeCommand { fn name(&self) -> &'static str { match self.operation { - EdgeOperation::Fillet => "SOLIDFILLET", - EdgeOperation::Chamfer => "SOLIDCHAMFER", + EdgeOperation::Fillet => "FILLETEDGE", + EdgeOperation::Chamfer => "CHAMFEREDGE", } } fn prompt(&self) -> String { - if self.picked.is_none() { - return crate::t!("Select a solid edge:").into_owned(); + match (self.operation, self.step, self.selection_mode) { + (EdgeOperation::Chamfer, EdgeStep::Selecting, _) => { + crate::t!("Select an edge or [Loop/Distance]:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::PickingLoop, _) => { + crate::t!("Select edge of loop or [Edge/Distance]:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm, _) => { + crate::t!("Press Enter to accept the chamfer or [Distance]:").into_owned() + } + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance1, _) => crate::tf!( + "Specify Distance1 or [Expression] <{:.4}>:", + self.default_value + ) + .into_owned(), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance2, _) => crate::tf!( + "Specify Distance2 or [Expression] <{:.4}>:", + self.other_value + ) + .into_owned(), + (_, EdgeStep::Selecting, EdgeSelectionMode::Edge) => { + crate::t!("Select an edge or [Chain/Loop/Radius]:").into_owned() + } + (_, EdgeStep::Selecting, EdgeSelectionMode::Chain) => { + crate::t!("Select an edge chain or [Edge/Radius]:").into_owned() + } + (_, EdgeStep::PickingLoop, _) => { + crate::t!("Select edge of loop or [Edge/Chain/Radius]:").into_owned() + } + (_, EdgeStep::LoopConfirm, _) => { + crate::t!("Enter an option [Accept/Next] :").into_owned() + } + (_, EdgeStep::PreviewConfirm, _) => { + crate::t!("Press Enter to accept the fillet or [Radius]:").into_owned() + } + (_, EdgeStep::Radius, _) if self.value_return == EdgeStep::PreviewConfirm => crate::tf!( + "Specify Radius or [Expression] <{:.4}>:", + self.default_value + ) + .into_owned(), + (_, EdgeStep::Radius, _) => crate::tf!( + "Enter fillet radius or [Expression] <{:.4}>:", + self.default_value + ) + .into_owned(), + (_, EdgeStep::Expression, _) => crate::t!("Enter expression:").into_owned(), + _ => String::new(), } - match self.operation { - EdgeOperation::Fillet => { - crate::tf!("Specify fillet radius <{:.3}>:", self.default_value).into_owned() + } + + fn options(&self) -> Vec { + match (self.operation, self.step, self.selection_mode) { + (EdgeOperation::Chamfer, EdgeStep::Selecting, _) => vec![ + CmdOption::new("Loop", "L"), + CmdOption::new("Distance", "D"), + ], + (EdgeOperation::Chamfer, EdgeStep::PickingLoop, _) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Distance", "D"), + ], + (EdgeOperation::Chamfer, EdgeStep::LoopConfirm, _) => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Next", "N"), + ], + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm, _) => { + vec![CmdOption::new("Distance", "D")] } - EdgeOperation::Chamfer => { - crate::tf!("Specify chamfer distance <{:.3}>:", self.default_value).into_owned() + ( + EdgeOperation::Chamfer, + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2, + _, + ) => vec![CmdOption::new("Expression", "E")], + (EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Edge) => vec![ + CmdOption::new("Chain", "C"), + CmdOption::new("Loop", "L"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::Selecting, EdgeSelectionMode::Chain) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::PickingLoop, _) => vec![ + CmdOption::new("Edge", "E"), + CmdOption::new("Chain", "C"), + CmdOption::new("Radius", "R"), + ], + (EdgeOperation::Fillet, EdgeStep::LoopConfirm, _) => vec![ + CmdOption::new("Accept", "A"), + CmdOption::new("Next", "N"), + ], + (EdgeOperation::Fillet, EdgeStep::PreviewConfirm, _) => { + vec![CmdOption::new("Radius", "R")] } + (EdgeOperation::Fillet, EdgeStep::Radius, _) => { + vec![CmdOption::new("Expression", "E")] + } + _ => Vec::new(), } } fn needs_entity_pick(&self) -> bool { - self.picked.is_none() + matches!(self.step, EdgeStep::Selecting | EdgeStep::PickingLoop) } fn entity_pick_includes_fills(&self) -> bool { @@ -80,52 +436,281 @@ impl CadCommand for SolidEdgeCommand { } fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { - if handle.is_null() || self.target.is_some_and(|target| target != handle) { + if !self.pick_allowed(handle) { return CmdResult::NeedPoint; } - self.picked = Some((handle, point)); + let Some(seed) = self + .body(handle) + .and_then(|body| crate::scene::model::solid_model::nearest_edge(body, point.to_array())) + else { + return CmdResult::NeedPoint; + }; + self.handle = Some(handle); + self.last_pick = Some(point); + + if self.step == EdgeStep::PickingLoop { + self.loop_candidates = edge_loops(self.body(handle).expect("pick body exists"), seed); + if self.operation == EdgeOperation::Chamfer { + if let Some(base_face) = self.base_face { + self.loop_candidates + .retain(|(face, _)| *face == base_face); + } + } + self.loop_index = 0; + self.step = if self.loop_candidates.is_empty() { + EdgeStep::Selecting + } else { + EdgeStep::LoopConfirm + }; + self.rebuild_preview(); + return CmdResult::NeedPoint; + } + + if self.operation == EdgeOperation::Chamfer { + if self.base_face.is_none() { + self.base_face = self.body(handle).and_then(|body| { + nearest_edge_face(body, seed, point.to_array()) + }); + } + let Some(base_face) = self.base_face else { + return CmdResult::NeedPoint; + }; + if !edge_belongs_to_face(self.body(handle).expect("pick body exists"), seed, base_face) + { + return CmdResult::NeedPoint; + } + self.add_batch(vec![seed], point); + self.rebuild_preview(); + return CmdResult::NeedPoint; + } + + let edges = match self.selection_mode { + EdgeSelectionMode::Edge => vec![seed], + EdgeSelectionMode::Chain => edge_chain(self.body(handle).expect("pick body exists"), seed), + }; + self.add_batch(edges, point); + self.rebuild_preview(); CmdResult::NeedPoint } fn on_point(&mut self, point: DVec3) -> CmdResult { - let Some((_, pick)) = self.picked else { + if !matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) { + return CmdResult::NeedPoint; + } + let Some(anchor) = self.last_pick else { return CmdResult::NeedPoint; }; - let value = point.distance(pick); - if value > 0.0 && value.is_finite() { - self.finish(value) - } else { - CmdResult::NeedPoint + let value = point.distance(anchor); + if value <= 0.0 || !value.is_finite() { + return CmdResult::NeedPoint; + } + match self.step { + EdgeStep::Radius => self.accept_radius(value).unwrap_or(CmdResult::NeedPoint), + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => { + self.accept_distance(value).unwrap_or(CmdResult::NeedPoint) + } + _ => CmdResult::NeedPoint, } } fn on_text_input(&mut self, text: &str) -> Option { - if self.picked.is_none() { - return None; + let keyword = text.trim().to_ascii_uppercase(); + if self.operation == EdgeOperation::Chamfer { + return match self.step { + EdgeStep::Selecting => match keyword.as_str() { + "L" | "LOOP" => { + self.step = EdgeStep::PickingLoop; + Some(CmdResult::NeedPoint) + } + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::Selecting)), + _ => None, + }, + EdgeStep::PickingLoop => match keyword.as_str() { + "E" | "EDGE" => { + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::PickingLoop)), + _ => None, + }, + EdgeStep::LoopConfirm => match keyword.as_str() { + "A" | "ACCEPT" => Some(self.accept_loop()), + "N" | "NEXT" if !self.loop_candidates.is_empty() => { + self.loop_index = (self.loop_index + 1) % self.loop_candidates.len(); + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PreviewConfirm => match keyword.as_str() { + "D" | "DISTANCE" => Some(self.begin_distances(EdgeStep::PreviewConfirm)), + _ => None, + }, + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => { + if matches!(keyword.as_str(), "E" | "EXPRESSION") { + self.expression_return = self.step; + self.step = EdgeStep::Expression; + Some(CmdResult::NeedPoint) + } else { + let value = keyword.parse::().ok()?; + self.accept_distance(value) + } + } + EdgeStep::Expression => { + let value = crate::app::expr_eval::eval_number(text)?; + self.step = self.expression_return; + self.accept_distance(value) + } + EdgeStep::Radius => None, + }; + } + + match self.step { + EdgeStep::Selecting => match keyword.as_str() { + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::Selecting)), + "C" | "CHAIN" => { + self.selection_mode = EdgeSelectionMode::Chain; + Some(CmdResult::NeedPoint) + } + "E" | "EDGE" if self.selection_mode == EdgeSelectionMode::Chain => { + self.selection_mode = EdgeSelectionMode::Edge; + Some(CmdResult::NeedPoint) + } + "L" | "LOOP" => { + self.step = EdgeStep::PickingLoop; + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PickingLoop => match keyword.as_str() { + "E" | "EDGE" => { + self.selection_mode = EdgeSelectionMode::Edge; + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "C" | "CHAIN" => { + self.selection_mode = EdgeSelectionMode::Chain; + self.step = EdgeStep::Selecting; + Some(CmdResult::NeedPoint) + } + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::PickingLoop)), + _ => None, + }, + EdgeStep::LoopConfirm => match keyword.as_str() { + "A" | "ACCEPT" => Some(self.accept_loop()), + "N" | "NEXT" if !self.loop_candidates.is_empty() => { + self.loop_index = (self.loop_index + 1) % self.loop_candidates.len(); + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + _ => None, + }, + EdgeStep::PreviewConfirm => match keyword.as_str() { + "R" | "RADIUS" => Some(self.begin_radius(EdgeStep::PreviewConfirm)), + _ => None, + }, + EdgeStep::Radius => { + if matches!(keyword.as_str(), "E" | "EXPRESSION") { + self.step = EdgeStep::Expression; + return Some(CmdResult::NeedPoint); + } + let value = keyword.parse::().ok()?; + self.accept_radius(value) + } + EdgeStep::Expression => { + let value = crate::app::expr_eval::eval_number(text)?; + self.accept_radius(value) + } + EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 => None, } - let value = text.trim().parse::().ok()?; - (value > 0.0 && value.is_finite()).then(|| self.finish(value)) } fn on_enter(&mut self) -> CmdResult { - if self.picked.is_some() { - self.finish(self.default_value) - } else { - CmdResult::Cancel + match (self.operation, self.step) { + (EdgeOperation::Chamfer, EdgeStep::Selecting) if !self.selected_edges.is_empty() => { + self.step = EdgeStep::PreviewConfirm; + self.rebuild_preview(); + CmdResult::NeedPoint + } + (EdgeOperation::Chamfer, EdgeStep::LoopConfirm) => self.accept_loop(), + (EdgeOperation::Chamfer, EdgeStep::PreviewConfirm) => self.finish(), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance1) => self + .accept_distance(self.default_value) + .unwrap_or(CmdResult::NeedPoint), + (EdgeOperation::Chamfer, EdgeStep::ChamferDistance2) => self + .accept_distance(self.other_value) + .unwrap_or(CmdResult::NeedPoint), + (EdgeOperation::Chamfer, EdgeStep::Expression) => CmdResult::NeedPoint, + (EdgeOperation::Chamfer, _) => CmdResult::Cancel, + (_, EdgeStep::Selecting) if !self.selected_edges.is_empty() => { + self.step = EdgeStep::PreviewConfirm; + self.rebuild_preview(); + CmdResult::NeedPoint + } + (_, EdgeStep::LoopConfirm) => self.accept_loop(), + (_, EdgeStep::PreviewConfirm) => self.finish(), + (_, EdgeStep::Radius) => self + .accept_radius(self.default_value) + .unwrap_or(CmdResult::NeedPoint), + (_, EdgeStep::Expression) => CmdResult::NeedPoint, + _ => CmdResult::Cancel, } } + fn on_undo_step(&mut self) -> Option { + if self.step == EdgeStep::LoopConfirm { + self.loop_candidates.clear(); + self.loop_index = 0; + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + return Some(CmdResult::NeedPoint); + } + let (batch, _) = self.selection_batches.pop()?; + for edge in batch { + if let Some(index) = self.selected_edges.iter().position(|candidate| *candidate == edge) { + self.selected_edges.remove(index); + } + } + if self.selected_edges.is_empty() { + self.handle = None; + self.base_face = None; + } + self.last_pick = self + .selection_batches + .last() + .map(|(_, anchor)| *anchor); + self.step = EdgeStep::Selecting; + self.rebuild_preview(); + Some(CmdResult::NeedPoint) + } + fn wants_text_input(&self) -> bool { - self.picked.is_some() + matches!( + self.step, + EdgeStep::Radius + | EdgeStep::Expression + | EdgeStep::ChamferDistance1 + | EdgeStep::ChamferDistance2 + ) } fn dyn_commit_as_text(&self) -> bool { - self.picked.is_some() + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) } fn dyn_spec(&self) -> Option { - let (_, pick) = self.picked?; - Some(DynSpec { + let pick = self.last_pick?; + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) + .then(|| DynSpec { anchor: DynAnchor::Point(pick), fields: vec![DynFieldSpec::new(match self.operation { EdgeOperation::Fillet => DynRole::Radius, @@ -137,10 +722,163 @@ impl CadCommand for SolidEdgeCommand { } fn dyn_live_value(&self, cursor: DVec3) -> Option { - self.picked.map(|(_, pick)| cursor.distance(pick)) + matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) + .then(|| self.last_pick.map(|pick| cursor.distance(pick))) + .flatten() + } + + fn on_preview_wires(&mut self, cursor: DVec3) -> Vec { + if matches!( + self.step, + EdgeStep::Radius | EdgeStep::ChamferDistance1 | EdgeStep::ChamferDistance2 + ) { + if let Some(value) = self.last_pick.map(|pick| cursor.distance(pick)) { + if value > 0.0 && value.is_finite() { + let values = match self.step { + EdgeStep::ChamferDistance2 => (self.default_value, value), + _ => (value, self.other_value), + }; + if let Some((handle, wires)) = self.preview_for_values(values.0, values.1) { + self.preview_wires = wires; + self.preview_hidden.clear(); + self.preview_hidden.push(handle); + } else { + self.preview_wires.clear(); + self.preview_hidden.clear(); + } + } + } + } + self.preview_wires.clone() + } + + fn preview_hidden_handles(&self) -> &[Handle] { + &self.preview_hidden } } +fn edge_loops(body: &Body, seed: EdgeKey) -> Vec<(FaceKey, Vec)> { + let Some(edge) = body.edges.get(seed) else { + return Vec::new(); + }; + let mut candidates = Vec::new(); + for coedge_key in &edge.coedges { + let Some(coedge) = body.coedges.get(*coedge_key) else { + continue; + }; + let Some(edge_loop) = body.loops.get(coedge.owner) else { + continue; + }; + let edges = edge_loop + .coedges + .iter() + .filter_map(|key| body.coedges.get(*key).map(|coedge| coedge.edge)) + .collect::>(); + let candidate = (edge_loop.owner, edges); + if !candidate.1.is_empty() && !candidates.contains(&candidate) { + candidates.push(candidate); + } + } + candidates +} + +fn edge_belongs_to_face(body: &Body, edge: EdgeKey, face: FaceKey) -> bool { + body.edges.get(edge).is_some_and(|edge| { + edge.coedges.iter().any(|coedge| { + body.coedges + .get(*coedge) + .and_then(|coedge| body.loops.get(coedge.owner)) + .is_some_and(|edge_loop| edge_loop.owner == face) + }) + }) +} + +fn nearest_edge_face(body: &Body, edge: EdgeKey, point: [f64; 3]) -> Option { + let edge = body.edges.get(edge)?; + edge.coedges + .iter() + .filter_map(|coedge| { + let edge_loop = body.loops.get(body.coedges.get(*coedge)?.owner)?; + let face = body.faces.get(edge_loop.owner)?; + let surface = body.surfaces.get(face.surface)?; + let distance = surface.distance_to(point).abs(); + distance.is_finite().then_some((edge_loop.owner, distance)) + }) + .min_by(|first, second| first.1.total_cmp(&second.1)) + .map(|(face, _)| face) +} + +fn positive_default(value: f64) -> f64 { + if value.is_finite() && value > 0.0 { + value + } else { + 1.0 + } +} + +fn edge_chain(body: &Body, seed: EdgeKey) -> Vec { + let mut chain = vec![seed]; + let mut next = 0; + while next < chain.len() { + let current_key = chain[next]; + next += 1; + let Some(current) = body.edges.get(current_key) else { + continue; + }; + for vertex in [current.start, current.end] { + let Some(current_tangent) = edge_tangent_from_vertex(body, current_key, vertex) else { + continue; + }; + for candidate_key in body.edge_keys() { + if chain.contains(&candidate_key) { + continue; + } + let Some(candidate) = body.edges.get(candidate_key) else { + continue; + }; + if candidate.start != vertex && candidate.end != vertex { + continue; + } + let Some(candidate_tangent) = + edge_tangent_from_vertex(body, candidate_key, vertex) + else { + continue; + }; + if current_tangent.dot(candidate_tangent) < -0.999 { + chain.push(candidate_key); + } + } + } + } + chain +} + +fn edge_tangent_from_vertex( + body: &Body, + edge_key: EdgeKey, + vertex: cadkernel::brep::VertexKey, +) -> Option { + let edge = body.edges.get(edge_key)?; + let curve = body.curves.get(edge.curve)?; + let span = edge.end_parameter - edge.start_parameter; + if !span.is_finite() || span.abs() <= f64::EPSILON { + return None; + } + let step = span * 1.0e-6; + let (at, inside) = if edge.start == vertex { + (edge.start_parameter, edge.start_parameter + step) + } else if edge.end == vertex { + (edge.end_parameter, edge.end_parameter - step) + } else { + return None; + }; + let tangent = DVec3::from(curve.point_at(inside)) - DVec3::from(curve.point_at(at)); + (tangent.length_squared() > 0.0 && tangent.is_finite()).then(|| tangent.normalize()) +} + inventory::submit!(crate::command::CommandRegistration { - names: &["SOLIDFILLET", "SOLIDCHAMFER"] + names: &["FILLETEDGE", "SOLIDFILLET", "CHAMFEREDGE", "SOLIDCHAMFER"] }); diff --git a/src/modules/model/mod.rs b/src/modules/model/mod.rs index 18ee79a87..546342d00 100644 --- a/src/modules/model/mod.rs +++ b/src/modules/model/mod.rs @@ -5,6 +5,8 @@ pub mod cylinder_cmd; pub mod edge_cmd; pub mod polysolid_cmd; pub mod primitive_cmd; +pub mod shell_cmd; +pub mod slice_cmd; use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef}; @@ -88,8 +90,9 @@ impl CadModule for ModelModule { RibbonGroup { title: "Edges", tools: vec![ - RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)), - RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)), + RibbonItem::LargeTool(tool("FILLETEDGE", "Fillet Edge", FILLET_ICON)), + RibbonItem::LargeTool(tool("CHAMFEREDGE", "Chamfer", CHAMFER_ICON)), + RibbonItem::LargeTool(tool("SHELL", "Shell", PRESSPULL_ICON)), ], }, ] diff --git a/src/modules/model/shell_cmd.rs b/src/modules/model/shell_cmd.rs new file mode 100644 index 000000000..7ceee1ef4 --- /dev/null +++ b/src/modules/model/shell_cmd.rs @@ -0,0 +1,308 @@ +use acadrust::Handle; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, InputKind, ShellFaceAction}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ShellStep { + SolidEditOption, + BodyOption, + SelectSolid, + RemoveFaces, + AddFaces, + Distance, +} + +pub struct ShellCommand { + entry_name: &'static str, + step: ShellStep, + target: Option, + actions: Vec, + default_distance: f64, +} + +impl ShellCommand { + pub fn direct(target: Option) -> Self { + Self { + entry_name: "SHELL", + step: if target.is_some() { + ShellStep::RemoveFaces + } else { + ShellStep::SelectSolid + }, + target, + actions: Vec::new(), + default_distance: 1.0, + } + } + + pub fn solid_edit(target: Option) -> Self { + Self { + entry_name: "SOLIDEDIT", + step: ShellStep::SolidEditOption, + target, + actions: Vec::new(), + default_distance: 1.0, + } + } + + fn finish(&mut self, distance: f64) -> CmdResult { + let Some(handle) = self.target else { + return CmdResult::Cancel; + }; + CmdResult::SolidShell { + handle, + actions: std::mem::take(&mut self.actions), + distance, + } + } + + fn undo_face_action(&mut self) -> CmdResult { + self.actions.pop(); + CmdResult::NeedPoint + } + + fn face_options(add: bool) -> Vec { + if add { + vec![ + CmdOption::new("Undo", "UNDO"), + CmdOption::new("Remove", "REMOVE"), + CmdOption::new("ALL", "ALL"), + ] + } else { + vec![ + CmdOption::new("Undo", "UNDO"), + CmdOption::new("Add", "ADD"), + CmdOption::new("ALL", "ALL"), + ] + } + } +} + +impl CadCommand for ShellCommand { + fn name(&self) -> &'static str { + self.entry_name + } + + fn prompt(&self) -> String { + match self.step { + ShellStep::SolidEditOption => { + crate::t!("Enter a solid editing option [Body/eXit]:").into_owned() + } + ShellStep::BodyOption => { + crate::t!("Enter a body editing option [Shell/eXit]:").into_owned() + } + ShellStep::SelectSolid => crate::t!("Select a 3D solid:").into_owned(), + ShellStep::RemoveFaces => { + crate::t!("Select faces to remove [Undo/Add/ALL] :").into_owned() + } + ShellStep::AddFaces => { + crate::t!("Select faces to add [Undo/Remove/ALL] :").into_owned() + } + ShellStep::Distance => crate::tf!( + "Specify the shell offset distance <{:.3}>:", + self.default_distance + ) + .into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + ShellStep::SolidEditOption => vec![ + CmdOption::new("Body", "BODY"), + CmdOption::new("eXit", "EXIT"), + ], + ShellStep::BodyOption => vec![ + CmdOption::new("Shell", "SHELL"), + CmdOption::new("eXit", "EXIT"), + ], + ShellStep::RemoveFaces => Self::face_options(false), + ShellStep::AddFaces => Self::face_options(true), + _ => Vec::new(), + } + } + + fn needs_entity_pick(&self) -> bool { + matches!( + self.step, + ShellStep::SelectSolid | ShellStep::RemoveFaces | ShellStep::AddFaces + ) + } + + fn entity_pick_includes_fills(&self) -> bool { + true + } + + fn entity_pick_uses_surface_point(&self) -> bool { + true + } + + fn entity_pick_highlights_hover(&self) -> bool { + true + } + + fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult { + if handle.is_null() { + return CmdResult::NeedPoint; + } + match self.step { + ShellStep::SelectSolid => { + self.target = Some(handle); + self.step = ShellStep::RemoveFaces; + } + ShellStep::RemoveFaces if self.target == Some(handle) => { + self.actions.push(ShellFaceAction::Remove(point)); + } + ShellStep::AddFaces if self.target == Some(handle) => { + self.actions.push(ShellFaceAction::Add(point)); + } + _ => {} + } + CmdResult::NeedPoint + } + + fn on_point(&mut self, _point: DVec3) -> CmdResult { + CmdResult::NeedPoint + } + + fn on_text_input(&mut self, text: &str) -> Option { + let value = text.trim(); + let keyword = value.to_ascii_uppercase(); + let result = match self.step { + ShellStep::SolidEditOption => match keyword.as_str() { + "B" | "BODY" => { + self.step = ShellStep::BodyOption; + CmdResult::NeedPoint + } + "X" | "EXIT" => CmdResult::Cancel, + _ => return Some(CmdResult::NeedPoint), + }, + ShellStep::BodyOption => match keyword.as_str() { + "S" | "SHELL" => { + self.step = if self.target.is_some() { + ShellStep::RemoveFaces + } else { + ShellStep::SelectSolid + }; + CmdResult::NeedPoint + } + "X" | "EXIT" => CmdResult::Cancel, + _ => return Some(CmdResult::NeedPoint), + }, + ShellStep::RemoveFaces => match keyword.as_str() { + "U" | "UNDO" => return Some(self.undo_face_action()), + "A" | "ADD" => { + self.step = ShellStep::AddFaces; + CmdResult::NeedPoint + } + "ALL" => { + self.actions.push(ShellFaceAction::RemoveAll); + CmdResult::NeedPoint + } + _ => return Some(CmdResult::NeedPoint), + }, + ShellStep::AddFaces => match keyword.as_str() { + "U" | "UNDO" => return Some(self.undo_face_action()), + "R" | "REMOVE" => { + self.step = ShellStep::RemoveFaces; + CmdResult::NeedPoint + } + "ALL" => { + self.actions.push(ShellFaceAction::AddAll); + CmdResult::NeedPoint + } + _ => return Some(CmdResult::NeedPoint), + }, + ShellStep::Distance => { + let Ok(distance) = value.parse::() else { + return Some(CmdResult::ReportError( + crate::t!("A numeric shell offset distance is required.").into_owned(), + )); + }; + if !distance.is_finite() || distance.abs() <= f64::EPSILON { + return Some(CmdResult::ReportError( + crate::t!("Value must be nonzero.").into_owned(), + )); + } + self.default_distance = distance; + self.finish(distance) + } + ShellStep::SelectSolid => return None, + }; + Some(result) + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + ShellStep::RemoveFaces | ShellStep::AddFaces => { + self.step = ShellStep::Distance; + CmdResult::NeedPoint + } + ShellStep::Distance => self.finish(self.default_distance), + _ => CmdResult::Cancel, + } + } + + fn on_undo_step(&mut self) -> Option { + matches!(self.step, ShellStep::RemoveFaces | ShellStep::AddFaces) + .then(|| self.undo_face_action()) + } + + fn input_kind(&self) -> InputKind { + if matches!(self.step, ShellStep::SelectSolid) { + InputKind::Point + } else { + InputKind::SingleToken + } + } +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SHELL", "SOLIDEDIT"] +}); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn direct_shell_collects_face_edits_and_signed_distance() { + let handle = Handle::new(42); + let first = DVec3::new(1.0, 2.0, 3.0); + let second = DVec3::new(4.0, 5.0, 6.0); + let mut command = ShellCommand::direct(Some(handle)); + + command.on_entity_pick(handle, first); + command.on_text_input("ADD"); + command.on_entity_pick(handle, second); + command.on_text_input("UNDO"); + command.on_text_input("ALL"); + assert!(matches!(command.on_enter(), CmdResult::NeedPoint)); + + assert!(matches!( + command.on_text_input("-0.5"), + Some(CmdResult::SolidShell { + handle: result_handle, + actions, + distance: -0.5, + }) if result_handle == handle + && actions == vec![ShellFaceAction::Remove(first), ShellFaceAction::AddAll] + )); + } + + #[test] + fn invalid_distance_keeps_the_distance_step_active() { + let mut command = ShellCommand::direct(Some(Handle::new(7))); + command.on_enter(); + + assert!(matches!( + command.on_text_input("zero"), + Some(CmdResult::ReportError(_)) + )); + assert!(matches!( + command.on_text_input("0.25"), + Some(CmdResult::SolidShell { distance: 0.25, .. }) + )); + } +} diff --git a/src/modules/model/slice_cmd.rs b/src/modules/model/slice_cmd.rs new file mode 100644 index 000000000..b64909bb8 --- /dev/null +++ b/src/modules/model/slice_cmd.rs @@ -0,0 +1,510 @@ +use acadrust::{EntityType, Handle}; +use cadkernel::space::Plane; +use glam::DVec3; + +use crate::command::{CadCommand, CmdOption, CmdResult, SelectionEntity, WorkingPlane}; +use crate::scene::model::wire_model::WireModel; + +#[derive(Clone, Copy)] +enum PlaneKind { + Xy, + Yz, + Zx, + View, +} + +enum Step { + Targets, + FirstPoint, + ThreeFirst, + ThreeSecond(DVec3), + ThirdPoint(DVec3, DVec3), + AxisFirst, + AxisSecond(DVec3), + PlanePoint(PlaneKind), + PickObject, + PickSurface, + Side(Plane), + SurfaceSide, +} + +/// Interactive front-end for plane-based SLICE. +pub struct SliceCommand { + step: Step, + targets: Vec, + selected: Vec, + working: WorkingPlane, + view_normal: DVec3, + preview_center: DVec3, + preview_radius: f64, + picked: Option, + surface_tool: Option, + notice: Option<&'static str>, +} + +impl SliceCommand { + pub fn new( + targets: Vec, + view_normal: DVec3, + preview_center: DVec3, + preview_radius: f64, + ) -> Self { + let step = if targets.is_empty() { Step::Targets } else { Step::FirstPoint }; + Self { + step, + targets, + selected: Vec::new(), + working: WorkingPlane::default(), + view_normal: view_normal.normalize_or(DVec3::Z), + preview_center, + preview_radius: preview_radius.max(1.0), + picked: None, + surface_tool: None, + notice: None, + } + } + + fn plane(origin: DVec3, x: DVec3, normal: DVec3) -> Option { + Plane::orthonormal(origin.to_array(), x.to_array(), normal.to_array()) + } + + fn enter_side(&mut self, plane: Plane) -> CmdResult { + self.notice = None; + self.step = Step::Side(plane); + CmdResult::NeedPoint + } + + fn finish(&mut self, keep_point: Option) -> CmdResult { + let Step::Side(plane) = self.step else { + return CmdResult::NeedPoint; + }; + CmdResult::SliceEntities { + targets: std::mem::take(&mut self.targets), + plane, + keep_point, + } + } + + fn picked_plane(&self) -> Option { + let entity = self.picked.as_ref()?; + if !matches!( + entity, + EntityType::Arc(_) + | EntityType::Circle(_) + | EntityType::Ellipse(_) + | EntityType::Spline(_) + | EntityType::LwPolyline(_) + | EntityType::Polyline(_) + ) { + return None; + } + crate::scene::model::presspull_model::profile_geometry(entity) + .map(|(plane, _, _)| plane) + } + + fn finish_surface(&mut self, keep_point: Option) -> CmdResult { + let Some(cutter) = self.surface_tool.take() else { + return CmdResult::NeedPoint; + }; + CmdResult::SliceSurfaceEntities { + targets: std::mem::take(&mut self.targets), + cutter: Box::new(cutter), + keep_point, + } + } + + fn plane_at(&self, point: DVec3, kind: PlaneKind) -> Option { + let (x, normal) = match kind { + PlaneKind::Xy => (self.working.x, self.working.z), + PlaneKind::Yz => (self.working.y, self.working.x), + PlaneKind::Zx => (self.working.z, self.working.y), + PlaneKind::View => { + let candidate = if self.working.x.dot(self.view_normal).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + (candidate, self.view_normal) + } + }; + Self::plane(point, x, normal) + } +} + +impl CadCommand for SliceCommand { + fn name(&self) -> &'static str { + "SLICE" + } + + fn prompt(&self) -> String { + if let Some(notice) = self.notice { + return crate::t!(notice).into_owned(); + } + match self.step { + Step::Targets => crate::t!( + "SLICE Select solids or surfaces to slice, then press Enter:" + ) + .into_owned(), + Step::FirstPoint => crate::t!( + "SLICE Specify first point on slicing plane or [planar Object/Surface/Zaxis/View/XY/YZ/ZX/3points] <3points>:" + ) + .into_owned(), + Step::ThreeFirst => { + crate::t!("SLICE Specify first point on slicing plane:").into_owned() + } + Step::ThreeSecond(_) => { + crate::t!("SLICE Specify second point on slicing plane:").into_owned() + } + Step::ThirdPoint(_, _) => { + crate::t!("SLICE Specify third point on slicing plane:").into_owned() + } + Step::AxisFirst => { + crate::t!("SLICE Specify a point on the slicing plane:").into_owned() + } + Step::AxisSecond(_) => { + crate::t!("SLICE Specify a point on the plane's normal axis:").into_owned() + } + Step::PlanePoint(PlaneKind::View) => { + crate::t!("SLICE Specify a point on the current view plane:").into_owned() + } + Step::PlanePoint(_) => { + crate::t!("SLICE Specify a point on the current UCS plane:").into_owned() + } + Step::PickObject => crate::t!("SLICE Select a planar curve object:").into_owned(), + Step::PickSurface => crate::t!("SLICE Select a cutting surface:").into_owned(), + Step::Side(_) => crate::t!( + "SLICE Specify a point on the desired side or [Both] :" + ) + .into_owned(), + Step::SurfaceSide => crate::t!( + "SLICE Select a sliced object to keep or [Both] :" + ) + .into_owned(), + } + } + + fn options(&self) -> Vec { + match self.step { + Step::FirstPoint => vec![ + CmdOption::new(crate::t!("planar Object").as_ref(), "O"), + CmdOption::new(crate::t!("Surface").as_ref(), "S"), + CmdOption::new(crate::t!("Zaxis").as_ref(), "Z"), + CmdOption::new(crate::t!("View").as_ref(), "V"), + CmdOption::new("XY", "XY"), + CmdOption::new("YZ", "YZ"), + CmdOption::new("ZX", "ZX"), + CmdOption::new(crate::t!("3points").as_ref(), "3"), + ], + Step::Side(_) | Step::SurfaceSide => { + vec![CmdOption::new(crate::t!("Both").as_ref(), "B")] + } + _ => Vec::new(), + } + } + + fn set_working_plane(&mut self, plane: WorkingPlane) { + self.working = plane; + } + + fn on_point(&mut self, point: DVec3) -> CmdResult { + self.notice = None; + match self.step { + Step::FirstPoint => { + self.step = Step::ThreeSecond(point); + CmdResult::NeedPoint + } + Step::ThreeFirst => { + self.step = Step::ThreeSecond(point); + CmdResult::NeedPoint + } + Step::ThreeSecond(first) => { + self.step = Step::ThirdPoint(first, point); + CmdResult::NeedPoint + } + Step::ThirdPoint(first, second) => { + let x = second - first; + let normal = x.cross(point - first); + Self::plane(first, x, normal) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)) + } + Step::AxisFirst => { + self.step = Step::AxisSecond(point); + CmdResult::NeedPoint + } + Step::AxisSecond(origin) => { + let normal = point - origin; + let x = if self.working.x.dot(normal.normalize_or(DVec3::Z)).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + Self::plane(origin, x, normal) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)) + } + Step::PlanePoint(kind) => self + .plane_at(point, kind) + .map_or(CmdResult::NeedPoint, |plane| self.enter_side(plane)), + Step::Side(_) => self.finish(Some(point)), + Step::SurfaceSide => self.finish_surface(Some(point)), + _ => CmdResult::NeedPoint, + } + } + + fn on_enter(&mut self) -> CmdResult { + match self.step { + Step::Targets if !self.selected.is_empty() => { + self.targets = std::mem::take(&mut self.selected); + self.step = Step::FirstPoint; + CmdResult::DeselectAndContinue + } + Step::FirstPoint => { + self.step = Step::ThreeFirst; + CmdResult::NeedPoint + } + Step::Side(_) => self.finish(None), + Step::SurfaceSide => self.finish_surface(None), + _ => CmdResult::NeedPoint, + } + } + + fn on_text_input(&mut self, text: &str) -> Option { + let keyword = text.trim().to_ascii_uppercase(); + Some(match self.step { + Step::FirstPoint => match keyword.as_str() { + "O" | "OBJECT" => { + self.step = Step::PickObject; + CmdResult::NeedPoint + } + "S" | "SURFACE" => { + self.step = Step::PickSurface; + CmdResult::NeedPoint + } + "Z" | "ZAXIS" => { + self.step = Step::AxisFirst; + CmdResult::NeedPoint + } + "V" | "VIEW" => { + self.step = Step::PlanePoint(PlaneKind::View); + CmdResult::NeedPoint + } + "XY" => { + self.step = Step::PlanePoint(PlaneKind::Xy); + CmdResult::NeedPoint + } + "YZ" => { + self.step = Step::PlanePoint(PlaneKind::Yz); + CmdResult::NeedPoint + } + "ZX" | "XZ" => { + self.step = Step::PlanePoint(PlaneKind::Zx); + CmdResult::NeedPoint + } + "3" | "3P" | "3POINTS" => { + self.step = Step::ThreeFirst; + CmdResult::NeedPoint + } + _ => CmdResult::NeedPoint, + }, + Step::Side(_) if matches!(keyword.as_str(), "" | "B" | "BOTH") => self.finish(None), + Step::SurfaceSide if matches!(keyword.as_str(), "" | "B" | "BOTH") => { + self.finish_surface(None) + } + _ => CmdResult::NeedPoint, + }) + } + + fn point_step_accepts_keywords(&self) -> bool { + matches!(self.step, Step::FirstPoint | Step::Side(_) | Step::SurfaceSide) + } + + fn is_selection_gathering(&self) -> bool { + matches!(self.step, Step::Targets) + } + + fn selection_forces_add(&self) -> bool { + true + } + + fn inject_selection_entities(&mut self, entities: Vec) { + self.selected = entities + .into_iter() + .filter_map(|item| { + matches!(item.entity, EntityType::Solid3D(_) | EntityType::Surface(_)) + .then_some(item.handle) + }) + .collect(); + } + + fn on_selection_complete(&mut self, _handles: Vec) -> CmdResult { + CmdResult::NeedPoint + } + + fn needs_entity_pick(&self) -> bool { + matches!( + self.step, + Step::PickObject | Step::PickSurface | Step::SurfaceSide + ) + } + + fn entity_pick_highlights_hover(&self) -> bool { + self.needs_entity_pick() + } + + fn inject_before_entity_pick(&self) -> bool { + matches!(self.step, Step::PickObject | Step::PickSurface) + } + + fn entity_pick_uses_surface_point(&self) -> bool { + matches!(self.step, Step::SurfaceSide) + } + + fn inject_picked_entity(&mut self, entity: EntityType) { + self.picked = Some(entity); + } + + fn on_entity_pick(&mut self, _handle: Handle, _point: DVec3) -> CmdResult { + if matches!(self.step, Step::SurfaceSide) { + return self.finish_surface(Some(_point)); + } + let surface_only = matches!(self.step, Step::PickSurface); + if surface_only { + let body = self.picked.as_ref().and_then(|entity| match entity { + EntityType::Surface(surface) => { + crate::scene::convert::solid3d_tess::kernel_surface_body(surface) + } + _ => None, + }); + if let Some(body) = body { + if let Some(plane) = planar_body_plane(&body) { + return self.enter_side(plane); + } + if body.face_keys().count() == 1 { + self.surface_tool = Some(body); + self.notice = None; + self.step = Step::SurfaceSide; + return CmdResult::NeedPoint; + } + } + self.notice = Some( + "SLICE The selected surface does not define one supported cutting sheet. Select another surface:", + ); + return CmdResult::NeedPoint; + } + match self.picked_plane() { + Some(plane) => self.enter_side(plane), + None => { + self.notice = Some( + "SLICE The selected object does not define a plane. Select a planar curve object:", + ); + CmdResult::NeedPoint + } + } + } + + fn on_preview_wires(&mut self, point: DVec3) -> Vec { + let mut wires = Vec::new(); + let plane = match self.step { + Step::ThirdPoint(first, second) => { + wires.push(WireModel::solid_f64( + "slice_triangle".into(), + vec![ + first.to_array(), + second.to_array(), + point.to_array(), + first.to_array(), + ], + WireModel::CYAN, + false, + )); + Self::plane(first, second - first, (second - first).cross(point - first)) + } + Step::AxisSecond(origin) => { + wires.push(WireModel::solid_f64( + "slice_normal".into(), + vec![origin.to_array(), point.to_array()], + WireModel::CYAN, + false, + )); + let normal = point - origin; + let x = if self.working.x.dot(normal.normalize_or(DVec3::Z)).abs() < 0.98 { + self.working.x + } else { + self.working.y + }; + Self::plane(origin, x, normal) + } + Step::PlanePoint(kind) => self.plane_at(point, kind), + Step::Side(plane) => Some(plane), + _ => None, + }; + if let Some(plane) = plane { + wires.extend(plane_grid( + plane, + self.preview_center, + self.preview_radius, + )); + } + wires + } +} + +fn planar_body_plane(body: &cadkernel::brep::Body) -> Option { + let mut faces = body.face_keys(); + let first = cadkernel::brep::planar_face_profile(body, faces.next()?)?.plane; + let normal = DVec3::from_array(first.normal()?); + let tolerance = cadkernel::brep::operation_tolerance(&[body]); + faces + .all(|face| { + let Some(profile) = cadkernel::brep::planar_face_profile(body, face) else { + return false; + }; + profile.plane.normal().is_some_and(|other| { + normal.dot(DVec3::from_array(other)).abs() >= 1.0 - 1e-9 + && first + .distance_to(profile.plane.origin) + .is_some_and(|gap| gap.abs() <= tolerance * 4.0) + }) + }) + .then_some(first) +} + +fn plane_grid(plane: Plane, centre: DVec3, radius: f64) -> Vec { + let normal = DVec3::from_array(plane.normal().unwrap_or([0.0, 0.0, 1.0])); + let projected = centre + - normal + * plane + .distance_to(centre.to_array()) + .unwrap_or_default(); + let x = DVec3::from_array(plane.x_axis); + let y = DVec3::from_array(plane.y_axis); + let color = [0.2, 0.55, 1.0, 1.0]; + (-5..=5) + .flat_map(|index| { + let offset = radius * index as f64 / 5.0; + [ + WireModel::solid_f64( + format!("slice_grid_x_{index}"), + vec![ + (projected + x * offset - y * radius).to_array(), + (projected + x * offset + y * radius).to_array(), + ], + color, + false, + ), + WireModel::solid_f64( + format!("slice_grid_y_{index}"), + vec![ + (projected + y * offset - x * radius).to_array(), + (projected + y * offset + x * radius).to_array(), + ], + color, + false, + ), + ] + }) + .collect() +} + +inventory::submit!(crate::command::CommandRegistration { + names: &["SLICE", "SL"] +}); diff --git a/src/scene/boundary.rs b/src/scene/boundary.rs index 020ba2e7e..c04691960 100644 --- a/src/scene/boundary.rs +++ b/src/scene/boundary.rs @@ -84,6 +84,38 @@ fn entity_curves_on_plane( } } +fn entity_boundary_source_on_plane( + entity: &EntityType, + plane: WorkingPlane, + tolerance: f64, +) -> Option { + let curves = entity_curves_on_plane(entity, plane, tolerance); + + if curves.is_empty() { + return None; + } + + let segments: Vec = curves + .iter() + .flat_map(|curve| { + curve + .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .windows(2) + .filter_map(|pair| { + let start = pair[0]; + let end = pair[1]; + + (start.iter().chain(&end).all(|value| value.is_finite()) + && start != end) + .then_some(Line { start, end }) + }) + .collect::>() + }) + .collect(); + + (!segments.is_empty()).then_some(BoundarySource { segments, curves }) +} + fn hatch_path_seed(path: &acadrust::entities::BoundaryPath) -> Option<[f64; 2]> { let mut ring = Vec::new(); for edge in &path.edges { @@ -731,10 +763,22 @@ impl Scene { .boundary_handles .iter() .filter_map(|source| { - all_sources - .get(source) - .cloned() - .map(|geometry| (*source, geometry)) + // Associative boundaries are explicit document references. + // During a grip drag their display wire can be preview-hidden, + // so prefer the live entity geometry over the resident wire cache. + let geometry = self + .document + .get_entity(*source) + .and_then(|entity| { + entity_boundary_source_on_plane( + entity, + plane, + WELD_TOLERANCE, + ) + }) + .or_else(|| all_sources.get(source).cloned()); + + geometry.map(|geometry| (*source, geometry)) }) .collect(); let segments: Vec<_> = sources @@ -817,17 +861,47 @@ impl Scene { .extend(segments); } for (&handle, source) in &mut sources { - source.curves = self + let curves = self .document .get_entity(handle) .map(|entity| entity_curves_on_plane(entity, plane, tolerance)) .unwrap_or_default(); - source.curves.retain(|curve| { - source.segments.iter().any(|segment| { - distance_to(curve, segment.start).max(distance_to(curve, segment.end)) - <= tolerance * 4.0 + + // Associative hatch regeneration must use the entity's live geometry, + // not a potentially one-frame-old display wire. Grip edits and STRETCH + // mutate the document before the resident wire cache catches up. + let live_segments: Vec = curves + .iter() + .flat_map(|curve| { + curve + .tessellate_angle(cadkernel::tessellation::DEFAULT_ANGLE) + .windows(2) + .filter_map(|pair| { + let start = pair[0]; + let end = pair[1]; + + (start.iter().chain(&end).all(|value| value.is_finite()) + && start != end) + .then_some(Line { start, end }) + }) + .collect::>() }) - }); + .collect(); + + if !live_segments.is_empty() { + source.segments = live_segments; + source.curves = curves; + } else { + // Fallback for entity types that do not expose usable native curves: + // retain the existing display-wire geometry. + source.curves = curves; + source.curves.retain(|curve| { + source.segments.iter().any(|segment| { + distance_to(curve, segment.start).max(distance_to(curve, segment.end)) + <= tolerance * 4.0 + }) + }); + } } sources } diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 9416faae6..307a9a526 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1108,7 +1108,7 @@ pub fn expand_insert( ); let mut first = first_batches.finalize(&name, selected, bg_color); for wire in &mut first { - if wire.render_instance.is_none() { + if !is_standalone_analytical_curve(wire) && wire.render_instance.is_none() { wire.render_instance = Some( crate::scene::model::instance_model::RenderInstance { source_id: crate::scene::model::instance_model::next_source_id(), @@ -1149,7 +1149,7 @@ pub fn expand_insert( if let Some(guard) = prototype_guard.as_mut() { let translation = transform_translation(&xform); for wire in &mut wires { - if wire.render_instance.is_none() { + if !is_standalone_analytical_curve(wire) && wire.render_instance.is_none() { wire.render_instance = Some(crate::scene::model::instance_model::RenderInstance { source_id: crate::scene::model::instance_model::next_source_id(), translation, @@ -1314,6 +1314,20 @@ fn aabb_pixel_size(local_aabb: [f32; 4], world_per_pixel: f32) -> f32 { w.max(h) / world_per_pixel } +fn is_standalone_analytical_curve(wire: &WireModel) -> bool { + wire.tangent_geoms.len() == 1 + && wire.fill_tris.is_empty() + && wire.pick_tris.is_empty() + && wire.text_verts.is_empty() + && matches!( + wire.tangent_geoms[0], + TangentGeom::Circle { .. } + | TangentGeom::PlanarCircle { .. } + | TangentGeom::Arc { .. } + | TangentGeom::PlanarEllipse { .. } + ) +} + struct ExpandCtx<'a> { cache: &'a BlockCache, ins_color: [f32; 4], @@ -1931,12 +1945,14 @@ fn expand_defn( .collect(); crate::scene::pick::xclip::clip_wires(&mut wires, &world_poly); for wire in &mut wires { - wire.render_instance = Some( - crate::scene::model::instance_model::RenderInstance { - source_id: crate::scene::model::instance_model::next_source_id(), - translation, - }, - ); + if !is_standalone_analytical_curve(wire) { + wire.render_instance = Some( + crate::scene::model::instance_model::RenderInstance { + source_id: crate::scene::model::instance_model::next_source_id(), + translation, + }, + ); + } } out.extra_wires.extend(wires.iter().cloned()); first = Some((wires, translation)); @@ -1984,7 +2000,7 @@ fn expand_defn( ); let mut wires = sub.finalize("", ctx.selected, ctx.bg_color); for wire in &mut wires { - if wire.render_instance.is_none() { + if !is_standalone_analytical_curve(wire) && wire.render_instance.is_none() { wire.render_instance = Some( crate::scene::model::instance_model::RenderInstance { source_id: crate::scene::model::instance_model::next_source_id(), @@ -2297,6 +2313,134 @@ fn emit_wire( lw.line_weight_px }; + // Analytical GPU circle / arc / ellipse fast path: + // If this LocalWire is a pure analytical curve (single tangent geometry, no fills/text/pick), + // and transforms cleanly with accum_xform into an analytical curve, emit it as a standalone + // wire in extra_wires. This lets partition_wires extract it into CircleGpu / EllipseGpu for + // pixel-perfect analytical rendering on the GPU instead of segmented lines. + if !lw.tangent_geoms.is_empty() + && lw.fill_tris.is_empty() + && lw.pick_tris.is_empty() + && lw.text_verts.is_empty() + { + let transformed_tangents: Option> = lw + .tangent_geoms + .iter() + .map(|tg| transform_tangent(tg, accum_xform)) + .collect(); + if let Some(tangents) = transformed_tangents { + if tangents.iter().all(|tangent| { + matches!( + tangent, + TangentGeom::Circle { .. } + | TangentGeom::PlanarCircle { .. } + | TangentGeom::Arc { .. } + | TangentGeom::PlanarEllipse { .. } + ) + }) { + let contrast_bg = lw.contrast_bg.unwrap_or(ctx.bg_color); + let color = if lw.canvas_color { + ctx.bg_color + } else if lw.preserve_color { + final_color + } else { + crate::scene::view::render::adapt_to_bg(final_color, contrast_bg) + }; + let bg_adapt: crate::scene::model::wire_model::BgAdapt = + (lw.canvas_color || !lw.preserve_color).then(|| { + Box::new(crate::scene::model::wire_model::BgAdaptInputs { + raw_color: final_color, + text_raw_colors: Vec::new(), + contrast_bg: lw.contrast_bg, + canvas_color: lw.canvas_color, + preserve_color: lw.preserve_color, + }) + }); + + let mut points = Vec::with_capacity(lw.points.len()); + let mut points_low = Vec::with_capacity(lw.points.len()); + let mut min_x = f32::INFINITY; + let mut min_y = f32::INFINITY; + let mut max_x = f32::NEG_INFINITY; + let mut max_y = f32::NEG_INFINITY; + for (idx, p) in lw.points.iter().enumerate() { + let pl = lw.points_low.get(idx).copied().unwrap_or([0.0; 3]); + let point = accum_xform.apply(Vector3::new( + p[0] as f64 + pl[0] as f64, + p[1] as f64 + pl[1] as f64, + p[2] as f64 + pl[2] as f64, + )); + let (hx, lx) = WireModel::split_ds(point.x); + let (hy, ly) = WireModel::split_ds(point.y); + let (hz, lz) = WireModel::split_ds(point.z); + if hx < min_x { min_x = hx; } + if hy < min_y { min_y = hy; } + if hx > max_x { max_x = hx; } + if hy > max_y { max_y = hy; } + points.push([hx, hy, hz]); + points_low.push([lx, ly, lz]); + } + let aabb = if min_x.is_infinite() { + WireModel::UNBOUNDED_AABB + } else { + [min_x, min_y, max_x, max_y] + }; + let mut snap_pts = Vec::with_capacity(lw.snap_pts.len()); + for (p, hint) in &lw.snap_pts { + let v = accum_xform.apply(Vector3::new(p.x, p.y, p.z)); + snap_pts.push((glam::DVec3::new(v.x, v.y, v.z), *hint)); + } + let mut key_vertices = Vec::with_capacity(lw.key_vertices.len()); + for p in &lw.key_vertices { + let v = accum_xform.apply(Vector3::new(p[0], p[1], p[2])); + key_vertices.push([v.x, v.y, v.z]); + } + let local_depth = if d_range != (0.0, 1.0) { + Some(d_range.0) + } else { + None + }; + + let wire = WireModel { + bg_adapt, + point_marker: lw.point_marker, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: local_depth, + display_visible: !lw.hide_unselected || ctx.selected, + plot_visible: lw.plot_visible, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: String::new(), + points, + points_low, + color, + selected: ctx.selected, + pattern_length: final_pat_len, + pattern: final_pat, + line_weight_px: final_lw_px, + aci: final_aci, + snap_pts, + tangent_geoms: tangents, + key_vertices, + aabb, + plinegen: lw.plinegen, + fill_tris: Vec::new(), + fill_tris_low: Vec::new(), + }; + out.extra_wires.push(wire); + return; + } + } + } + let station_values = pattern_station_values(&lw.pattern_stations, lw.points.len()); let station_map = decode_pattern_station_map(&lw.pattern_stations, lw.points.len()); let transformed_stations = station_values.and_then(|(values, _)| { diff --git a/src/scene/convert/acis_export.rs b/src/scene/convert/acis_export.rs index 48e3730ce..42875bb84 100644 --- a/src/scene/convert/acis_export.rs +++ b/src/scene/convert/acis_export.rs @@ -21,3 +21,54 @@ pub fn solid_to_sat(body: &Body) -> Option { let binary = SabWriter::write(&document); valid(&SabReader::read(&binary).ok()?).then_some(document) } + +#[cfg(test)] +mod tests { + use super::*; + use cadkernel::geom2d::{Arc, Curve}; + use cadkernel::space::Plane; + + fn circle_section(z: f64, radius: f64) -> (Plane, Vec) { + let curves = (0..4) + .map(|part| { + let start = std::f64::consts::FRAC_PI_2 * part as f64; + Curve::Arc(Arc { + centre: [0.0, 0.0], + radius, + start_angle: start, + end_angle: start + std::f64::consts::FRAC_PI_2, + }) + }) + .collect(); + ( + Plane::from_axes([0.0, 0.0, z], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), + curves, + ) + } + + #[test] + fn a_curved_loft_round_trips_through_text_and_binary_acis() { + let body = cadkernel::brep::loft(&[ + circle_section(0.0, 5.0), + circle_section(10.0, 2.0), + ]) + .unwrap(); + assert!(body.validate().is_empty()); + + let mut document = SatDocument::new(); + cadkernel::acis::append(&body, &mut document).unwrap(); + let text = document.to_sat_string(); + let parsed = SatDocument::parse(&text).unwrap(); + let (restored, text_loss) = cadkernel::acis::lift(&parsed); + assert!(text_loss.is_empty(), "{text_loss:?}"); + assert_eq!(restored.len(), 1); + assert!(restored[0].validate().is_empty()); + + let binary = SabWriter::write(&parsed); + let parsed_binary = SabReader::read(&binary).unwrap(); + let (restored, binary_loss) = cadkernel::acis::lift(&parsed_binary); + assert!(binary_loss.is_empty(), "{binary_loss:?}"); + assert_eq!(restored.len(), 1); + assert!(restored[0].validate().is_empty()); + } +} diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index 5b74c78c6..c21c69364 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -177,6 +177,11 @@ pub fn tessellate_sat( color, None, )); + if let [single] = bodies.as_slice() { + if let Some(properties) = brep::analytic_mass_properties(&single.0) { + set.apply_mass_properties(properties); + } + } set.curved_gens = curved_gens; for point in edges { let high = [point[0] as f32, point[1] as f32, point[2] as f32]; diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index ebfa32df8..5095dd680 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -159,6 +159,10 @@ pub fn kernel_body(solid: &Solid3D) -> Option { kernel_acis_body(&solid.acis_data) } +pub fn kernel_region_body(region: &Region) -> Option { + kernel_acis_body(®ion.acis_data) +} + pub fn kernel_surface_body(surface: &Surface) -> Option { kernel_acis_body(&surface.acis_data) } diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 5d0044060..cbbfacd7a 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -19,7 +19,7 @@ use acadrust::{CadDocument, EntityType, Handle}; use glam::Vec3; use crate::scene::convert::acad_to_render::{convert, RenderObject}; -use crate::scene::model::wire_model::{SnapHint, WireModel}; +use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; /// Split an f64 offset-relative coordinate into the double-single (high, low) /// f32 pair the renderer consumes. `high + low ≈ value` to ~f64 precision; the @@ -202,6 +202,176 @@ pub(crate) fn points_to_ds( (high, low) } +fn split_mixed_polyline( + tangent_geoms: &[TangentGeom], + key_vertices: &[[f64; 3]], + name: &str, + color: [f32; 4], + selected: bool, + pattern_length: f32, + pattern: [f32; 8], + line_weight_px: f32, + snap_pts: Vec<(glam::DVec3, SnapHint)>, + point_marker: Option, + plinegen: bool, +) -> Vec { + let mut out = Vec::new(); + + // Emit each arc segment as an analytical wire (CircleGpu target) + for tg in tangent_geoms { + if let TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } = *tg + { + let mut arc_pts = Vec::with_capacity(17); + let n = 16; + let sweep = if end_angle >= start_angle { + end_angle - start_angle + } else { + end_angle - start_angle + std::f64::consts::TAU + }; + for s in 0..=n { + let frac = s as f64 / n as f64; + let ang = start_angle + frac * sweep; + let p = [ + center[0] + radius * (ang.cos() * axis_x[0] + ang.sin() * axis_y[0]), + center[1] + radius * (ang.cos() * axis_x[1] + ang.sin() * axis_y[1]), + center[2] + radius * (ang.cos() * axis_x[2] + ang.sin() * axis_y[2]), + ]; + arc_pts.push(p); + } + let (points, points_low) = points_to_ds(arc_pts); + out.push(WireModel { + bg_adapt: None, + point_marker: None, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.to_string(), + points, + points_low, + color, + selected, + pattern_length, + pattern, + line_weight_px, + snap_pts: Vec::new(), + tangent_geoms: vec![tg.clone()], + aci: 0, + key_vertices: Vec::new(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } + } + + // Collect straight lines into a line wire + let mut straight_pts: Vec<[f64; 3]> = Vec::new(); + let mut straight_tangents: Vec = Vec::new(); + let mut last_end: Option<[f64; 3]> = None; + + for (i, tg) in tangent_geoms.iter().enumerate() { + if let TangentGeom::Line { p1, p2 } = tg { + straight_tangents.push(tg.clone()); + let (p_start, p_end) = if i + 1 < key_vertices.len() { + (key_vertices[i], key_vertices[i + 1]) + } else { + ( + [p1[0] as f64, p1[1] as f64, p1[2] as f64], + [p2[0] as f64, p2[1] as f64, p2[2] as f64], + ) + }; + if !plinegen { + if !straight_pts.is_empty() { + straight_pts.push([f64::NAN; 3]); + } + straight_pts.push(p_start); + straight_pts.push(p_end); + } else { + if let Some(prev) = last_end { + if (prev[0] - p_start[0]).abs() < 1e-7 + && (prev[1] - p_start[1]).abs() < 1e-7 + && (prev[2] - p_start[2]).abs() < 1e-7 + { + straight_pts.push(p_end); + } else { + straight_pts.push([f64::NAN; 3]); + straight_pts.push(p_start); + straight_pts.push(p_end); + } + } else { + straight_pts.push(p_start); + straight_pts.push(p_end); + } + last_end = Some(p_end); + } + } else { + last_end = None; + } + } + + let (line_pts, line_pts_low) = points_to_ds(straight_pts); + if !line_pts.is_empty() { + out.push(WireModel { + bg_adapt: None, + point_marker, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: 0.0, + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d: false, + fill_is_2d_solid: false, + render_instance: None, + pick_tris: Vec::new(), + pick_tris_low: Vec::new(), + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.to_string(), + points: line_pts, + points_low: line_pts_low, + color, + selected, + pattern_length, + pattern, + line_weight_px, + snap_pts, + tangent_geoms: straight_tangents, + aci: 0, + key_vertices: key_vertices.to_vec(), + aabb: WireModel::UNBOUNDED_AABB, + plinegen, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } else if let Some(first_arc) = out.first_mut() { + first_arc.snap_pts = snap_pts; + first_arc.key_vertices = key_vertices.to_vec(); + } + + out +} + fn point_cloud_wires( document: &CadDocument, handle: Handle, @@ -707,9 +877,11 @@ pub fn tessellate( } // ── Try the kernel path first ─────────────────────────────────────────── + // Zoom-adaptive circles scale their segment count from the current zoom level and radius. // Relative-PDSIZE points size their glyph from the current zoom so they // stay a roughly constant on-screen size; otherwise the header-driven path. - let te = crate::entities::point::relative_render(entity, document, world_per_pixel) + let te = crate::entities::circle::relative_render(entity, document, world_per_pixel) + .or_else(|| crate::entities::point::relative_render(entity, document, world_per_pixel)) .or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel)) .or_else(|| match entity { EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale( @@ -1567,40 +1739,71 @@ pub fn tessellate( }; let point_marker = crate::entities::point::relative_marker_spec(entity, document); - out.push(WireModel { - bg_adapt: None, - point_marker, - taper_widths: Vec::new(), - pattern_stations: Vec::new(), - world_width: polyline_band_width(entity, document.header.fill_mode), - depth_override: None, - display_visible: true, - plot_visible: true, - fill_is_3d, - fill_is_2d_solid: false, - render_instance: None, - pick_tris, - pick_tris_low, - dash_from_start: false, - dash_align_end: None, - text_verts: Vec::new(), - name: name.clone(), - points: local_pts, - points_low: local_pts_low, - color: edge_color, - selected, - pattern_length: edge_pattern_length, - pattern: edge_pattern, - line_weight_px, - snap_pts: snap, - tangent_geoms: tangents, - aci: 0, - key_vertices: keys, - aabb: WireModel::UNBOUNDED_AABB, - plinegen: true, - fill_tris: vec![], - fill_tris_low: Vec::new(), - }); + + let has_arc = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Arc { .. })); + let has_line = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Line { .. })); + let is_thin_polyline = !is_thick_extrusion + && polyline_band_width(entity, document.header.fill_mode) <= 1e-9 + && fill_tris.is_empty() + && pick_tris.is_empty() + && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); + + if has_arc && has_line && is_thin_polyline { + out.extend(split_mixed_polyline( + &te.tangent_geoms, + &keys, + &name, + edge_color, + selected, + edge_pattern_length, + edge_pattern, + line_weight_px, + snap, + point_marker, + true, + )); + } else { + out.push(WireModel { + bg_adapt: None, + point_marker, + taper_widths: Vec::new(), + pattern_stations: Vec::new(), + world_width: polyline_band_width(entity, document.header.fill_mode), + depth_override: None, + display_visible: true, + plot_visible: true, + fill_is_3d, + fill_is_2d_solid: false, + render_instance: None, + pick_tris, + pick_tris_low, + dash_from_start: false, + dash_align_end: None, + text_verts: Vec::new(), + name: name.clone(), + points: local_pts, + points_low: local_pts_low, + color: edge_color, + selected, + pattern_length: edge_pattern_length, + pattern: edge_pattern, + line_weight_px, + snap_pts: snap, + tangent_geoms: tangents, + aci: 0, + key_vertices: keys, + aabb: WireModel::UNBOUNDED_AABB, + plinegen: true, + fill_tris: vec![], + fill_tris_low: Vec::new(), + }); + } } if !fill_tris.is_empty() { @@ -1756,6 +1959,48 @@ pub fn tessellate( // A wide polyline with PLINEGEN=0 arrives here: same shader-band // treatment as the Contour arm, restarting the dash per segment. let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); + let is_thick_extrusion = matches!( + entity, + EntityType::LwPolyline(p) if p.thickness.abs() > 1e-10 + ) || matches!( + entity, + EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10 + ); + let is_thin_polyline = !is_thick_extrusion + && polyline_band_width(entity, document.header.fill_mode) <= 1e-9 + && pick_tris.is_empty() + && matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_)); + let has_arc = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Arc { .. })); + let has_line = te + .tangent_geoms + .iter() + .any(|tg| matches!(tg, TangentGeom::Line { .. })); + + if has_arc && has_line && is_thin_polyline { + let edge_color = if is_thick_extrusion { + [0.0, 0.0, 0.0, 1.0] + } else { + color + }; + let point_marker = + crate::entities::point::relative_marker_spec(entity, document); + return split_mixed_polyline( + &te.tangent_geoms, + &key_vertices, + &name, + edge_color, + selected, + pattern_length, + pattern, + line_weight_px, + snap_pts, + point_marker, + false, + ); + } return vec![WireModel { bg_adapt: None, point_marker: None, diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 1fe0317b4..ca4609047 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -596,12 +596,15 @@ impl Scene { .filter_map(|&handle| { let from_history = self .document - .solid_history_operation(handle) - .and_then(|operation| cadkernel::acis::rebuild_body(operation).ok()); + .solid_history_operations(handle) + .and_then(|operations| cadkernel::acis::rebuild_history(&operations).ok()); let body = from_history.or_else(|| match self.document.get_entity(handle) { Some(EntityType::Solid3D(solid)) => { crate::scene::convert::solid3d_tess::kernel_body(solid) } + Some(EntityType::Region(region)) => { + crate::scene::convert::solid3d_tess::kernel_region_body(region) + } Some(EntityType::Surface(surface)) => { crate::scene::convert::solid3d_tess::kernel_surface_body(surface) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 844d6f5c2..5d86346ee 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -432,7 +432,8 @@ struct ResidentWireSet { layout: Option, } -struct ResidentWireLayout { +#[derive(Clone)] +pub(crate) struct ResidentWireLayout { /// Entity handles in final submission order. A temporarily hidden entity /// remains here with no range so grip commit can restore it in place. order: Vec, @@ -456,8 +457,16 @@ pub struct PreparedOpenGeometry { /// Loader tessellations and their guard; a state mismatch clears the memo. pub tess_memo: HashMap>>, pub tess_guard: u64, + /// The loader's submission-order layout for `wires`. + /// + /// Without it the installed set has `layout: None`, and `try_resident_patch` + /// refuses any set that has none — so the first edit after opening a file + /// could never patch and rebuilt the whole wire assembly instead. + pub(crate) resident_layout: Option, /// Loader block definitions; the receiving scene supplies its own epoch. pub block_defns: Vec<(u64, Arc)>, + /// Loader-computed draw-order map, re-stamped by the receiving scene. + pub(crate) draw_depths: Option, } impl std::fmt::Debug for PreparedOpenGeometry { @@ -578,7 +587,8 @@ fn inserted_draw_depth_label(order: &[DrawDepthEntry], position: usize) -> Optio } } -struct DrawDepthCache { +#[derive(Clone)] +pub(crate) struct DrawDepthCache { epoch: u64, depths: Arc>, /// Per-block stable order labels, including deleted tombstones retained for @@ -1047,6 +1057,18 @@ pub fn prepare_open_geometry( // Taken, not cloned: the scene is discarded on the next line. let tess_memo = std::mem::take(&mut *scene.resident_tess_memo.borrow_mut()); let tess_guard = scene.resident_tess_guard.get(); + let draw_depths = scene.draw_depth_cache.borrow_mut().take(); + // The layout the loader already built for exactly these wires. Taken by + // identity so it cannot be paired with a different assembly. + let resident_layout = scene + .resident_wire_sets + .borrow_mut() + .drain() + .find_map(|(_, set)| { + Arc::ptr_eq(&set.wires, &wires) + .then_some(set.layout) + .flatten() + }); let block_defns: Vec<(u64, Arc)> = std::mem::take(&mut *scene.block_defn_cache.borrow_mut()) .into_iter() @@ -1061,6 +1083,8 @@ pub fn prepare_open_geometry( tess_memo, tess_guard, block_defns, + draw_depths, + resident_layout, }, ) } @@ -1453,6 +1477,8 @@ pub(crate) enum NavPerfOp { Pan, Zoom, Rotate, + /// A message that changed geometry before the next rendered frame. + Edit, } impl NavPerfOp { @@ -1461,6 +1487,7 @@ impl NavPerfOp { Self::Pan => "pan", Self::Zoom => "zoom", Self::Rotate => "rotate", + Self::Edit => "edit", } } } @@ -1468,6 +1495,8 @@ impl NavPerfOp { #[derive(Clone, Copy, Debug)] pub(in crate::scene) struct NavPerfSample { pub(in crate::scene) op: NavPerfOp, + /// Message label that caused the sample. + pub(in crate::scene) cause: &'static str, pub(in crate::scene) space: &'static str, pub(in crate::scene) mode: &'static str, pub(in crate::scene) started: iced::time::Instant, @@ -1720,6 +1749,9 @@ pub struct Scene { /// constants. `[depth, half]` retains a fixed child sub-range for block /// composition. Full sort/layout/block changes rebuild the labels. draw_depth_cache: RefCell>, + /// Bumped when a full draw-order rebuild can move existing depths. + /// Cached uploads compare it before reusing baked depth values. + draw_depth_generation: std::cell::Cell, /// Shared empty map for 3-D wireframe, where true depth wins instead of /// the entity submission order used by the optimized 2-D style. no_draw_depths: Arc>, @@ -1868,11 +1900,16 @@ pub struct Scene { block_members_cache: RefCell>, /// Insert/Viewport/Block/BlockEnd handles omitted by the spatial index. unindexable_cache: RefCell)>>, - /// Distinct entity type names present in a layout, keyed by - /// (geometry_epoch, layout block). The status bar asks for this on every - /// frame to populate the selection-filter menu, but the answer only - /// changes when entities are added or removed. - layout_type_names_cache: RefCell>)>>, + /// `(epoch, layout block, present type names, list exposed to the UI)`. + /// The set lets pure additions update without rebuilding the list. + layout_type_names_cache: RefCell< + Option<( + u64, + Handle, + std::collections::BTreeSet, + std::sync::Arc>, + )>, + >, /// Reverse dependencies from layer/style/block definitions to the top-level /// entities whose resident wire runs actually change. Kept independent from /// `geometry_epoch`: a layer colour toggle can reuse the index, invalidate @@ -2080,6 +2117,7 @@ impl Scene { interaction_handle_index_cache: RefCell::new(None), sort_cache: RefCell::new(None), draw_depth_cache: RefCell::new(None), + draw_depth_generation: std::cell::Cell::new(0), no_draw_depths: Arc::new(HashMap::default()), hatch_cache: RefCell::new(HashMap::default()), wipeout_cache: RefCell::new(HashMap::default()), @@ -2276,7 +2314,7 @@ impl Scene { epoch: self.geometry_epoch, gen, wires: Arc::clone(&prepared.wires), - layout: None, + layout: prepared.resident_layout, }, ); // Adopt the loader thread's tessellation memo so the first edit patches @@ -2285,6 +2323,11 @@ impl Scene { *self.resident_tess_memo.borrow_mut() = prepared.tess_memo; self.resident_tess_guard.set(prepared.tess_guard); } + // Reuse the loader's draw-order map with this scene's epoch. + if let Some(mut depths) = prepared.draw_depths { + depths.epoch = self.geometry_epoch; + *self.draw_depth_cache.borrow_mut() = Some(depths); + } // Same for the block definitions, stamped with this scene's block // epoch: the document has not been touched between the loader // finishing and this call, so they describe it exactly. @@ -2937,6 +2980,15 @@ impl Scene { } pub(crate) fn record_nav_perf(&self, op: NavPerfOp, started: iced::time::Instant) { + self.record_nav_perf_caused(op, "-", started); + } + + pub(crate) fn record_nav_perf_caused( + &self, + op: NavPerfOp, + cause: &'static str, + started: iced::time::Instant, + ) { if !crate::perf::enabled() { return; } @@ -2949,6 +3001,7 @@ impl Scene { }; self.nav_perf_pending.set(Some(NavPerfSample { op, + cause, space, mode, started, @@ -5337,8 +5390,8 @@ impl Scene { { return arc; } - // Build once: full tessellation, no cull (region = None), no zoom LOD - // (wpp = None) — for every space, exactly like the Model static-hold. + // Build once: full tessellation, no cull, no zoom LOD — the resident + // set is zoom-independent (GPU analytical circles/arcs/ellipses). let t_tess = iced::time::Instant::now(); let mut wires = self.wires_for_block_culled( block, @@ -5389,6 +5442,9 @@ impl Scene { // spaces or re-scaling a viewport can't accumulate dead full sets. let cur_epoch = self.geometry_epoch; sets.retain(|_, set| set.epoch == cur_epoch); + if sets.len() > 16 { + sets.clear(); + } sets.insert( key, ResidentWireSet { @@ -5424,6 +5480,9 @@ impl Scene { mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); mix(all_visible as u64); mix(self.viewport_style_key(style_viewport)); + // quantized_wpp removed: GPU analytical rendering for circles/arcs/ + // ellipses makes tessellation zoom-independent, so the resident set is + // shared across all zoom levels. match frozen_layers { Some(frozen) => { let mut signature = 0u64; @@ -5438,6 +5497,18 @@ impl Scene { key } + /// Quantize world units per pixel (`wpp`) into half-octave bands (~1.414x zoom ratio). + /// + /// This provides discrete, stable zoom bands so that panning (where wpp is constant) + /// and small subpixel viewport drifts or mouse-wheel motions within a band do not + /// cause re-tessellation or GPU buffer re-uploads, while zooming past a band threshold + /// smoothly scales circle tessellation detail. + pub fn quantize_wpp(wpp: Option) -> Option { + let w = wpp.filter(|&w| w.is_finite() && w > 0.0)?; + let step = (w.log2() * 2.0).floor() * 0.5; + Some(2.0f32.powf(step)) + } + /// The GPU wire-arena handoff for a viewport whose content id is `gen`: /// `(prev_gen, changed handles)` when the Model set reached `gen` via an /// incremental resident patch, else `None`. Read (not consumed) so every @@ -5525,19 +5596,33 @@ impl Scene { frozen_layers: Option<&HashSet>, style_viewport: Option, ) -> Option>> { - if self.viewport_style_key(style_viewport) != 0 { - return None; - } let perf = crate::perf::enabled(); + // Each early exit here costs a full wire assembly, so say which one. + let declined = |reason: &str| -> Option>> { + if perf { + crate::perf_record!("[perf] resident-patch-skip reason={reason}"); + } + None + }; + if self.viewport_style_key(style_viewport) != 0 { + return declined("viewport-style"); + } let t_patch = iced::time::Instant::now(); // The entry must exist, be stale, and be uniquely held so we can move // its wires out rather than deep-clone them. let cached_epoch = { let sets = self.resident_wire_sets.borrow(); - let entry = sets.get(&key)?; - if entry.epoch == self.geometry_epoch || entry.layout.is_none() { + let Some(entry) = sets.get(&key) else { + drop(sets); + return declined("no-entry"); + }; + if entry.epoch == self.geometry_epoch { return None; } + if entry.layout.is_none() { + drop(sets); + return declined("no-layout"); + } let strong = Arc::strong_count(&entry.wires); if strong != 1 { if perf { @@ -5547,7 +5632,9 @@ impl Scene { } entry.epoch }; - let deltas = self.replay_since(cached_epoch)?; + let Some(deltas) = self.replay_since(cached_epoch) else { + return declined("journal-too-old"); + }; // Take ownership of the cached assembly (guaranteed unique above). // `prev_gen` is the content id the GPU currently holds for this set — the @@ -6026,6 +6113,11 @@ impl Scene { /// A full build assigns sparse labels in effective draw order. Incremental /// Add/Remove then changes only the named handle: existing siblings retain /// their depth, avoiding an O(all entities) map rewrite and GPU const upload. + /// See [`Self::draw_depth_generation`]. + pub(in crate::scene) fn draw_depth_generation(&self) -> u64 { + self.draw_depth_generation.get() + } + pub(super) fn draw_depth_map(&self) -> Arc> { { let cache = self.draw_depth_cache.borrow(); @@ -6155,6 +6247,11 @@ impl Scene { } } + // From here the labels are assigned afresh, so an existing entity's + // depth can move. Anything holding a baked depth has to rebuild. + self.draw_depth_generation + .set(self.draw_depth_generation.get().wrapping_add(1)); + use acadrust::objects::ObjectType; // Per-block SortEntitiesTable overrides: block -> (entity_val -> sort_val). let mut overrides: HashMap> = HashMap::default(); @@ -8342,24 +8439,33 @@ impl Scene { let Some(indices) = lookup.get(&handle) else { continue; }; - if indices - .iter() - .filter_map(|&index| meshes.get(index as usize)) - .any(|set| { - set.geometry_lods().first().is_some_and(|mesh| { - !pick::hit_test::mesh_box_hit( - a, - b, - crossing, - std::iter::once((handle, mesh, set.instance_transform)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) + let test = |set: &MeshLodSet| { + set.geometry_lods().first().is_some_and(|mesh| { + !pick::hit_test::mesh_box_hit( + a, + b, + crossing, + std::iter::once((handle, mesh, set.instance_transform)), + view_rot, + eye, + bounds, + ) + .is_empty() }) - { + }; + let mut sets = indices + .iter() + .filter_map(|&index| meshes.get(index as usize)); + let hit = if crossing { + sets.any(test) + } else { + let mut count = 0; + sets.all(|set| { + count += 1; + test(set) + }) && count > 0 + }; + if hit { out.push(handle); } } @@ -8510,23 +8616,32 @@ impl Scene { let Some(indices) = lookup.get(&handle) else { continue; }; - if indices - .iter() - .filter_map(|&index| meshes.get(index as usize)) - .any(|set| { - set.geometry_lods().first().is_some_and(|mesh| { - !pick::hit_test::mesh_poly_hit( - poly, - crossing, - std::iter::once((handle, mesh, set.instance_transform)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) + let test = |set: &MeshLodSet| { + set.geometry_lods().first().is_some_and(|mesh| { + !pick::hit_test::mesh_poly_hit( + poly, + crossing, + std::iter::once((handle, mesh, set.instance_transform)), + view_rot, + eye, + bounds, + ) + .is_empty() }) - { + }; + let mut sets = indices + .iter() + .filter_map(|&index| meshes.get(index as usize)); + let hit = if crossing { + sets.any(test) + } else { + let mut count = 0; + sets.all(|set| { + count += 1; + test(set) + }) && count > 0 + }; + if hit { out.push(handle); } } @@ -8768,23 +8883,32 @@ impl Scene { let Some(indices) = lookup.get(&handle) else { continue; }; - let hit = indices + let test = |set: &MeshLodSet| { + set.geometry_lods().first().is_some_and(|mesh| { + !pick::hit_test::mesh_box_hit( + a, + b, + crossing, + std::iter::once((handle, mesh, set.instance_transform)), + view_rot, + eye, + bounds, + ) + .is_empty() + }) + }; + let mut sets = indices .iter() - .filter_map(|&index| meshes.get(index as usize)) - .any(|set| { - set.geometry_lods().first().is_some_and(|mesh| { - !pick::hit_test::mesh_box_hit( - a, - b, - crossing, - std::iter::once((handle, mesh, set.instance_transform)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) - }); + .filter_map(|&index| meshes.get(index as usize)); + let hit = if crossing { + sets.any(test) + } else { + let mut count = 0; + sets.all(|set| { + count += 1; + test(set) + }) && count > 0 + }; if hit { out.push(handle); } @@ -8819,22 +8943,31 @@ impl Scene { let Some(indices) = lookup.get(&handle) else { continue; }; - let hit = indices + let test = |set: &MeshLodSet| { + set.geometry_lods().first().is_some_and(|mesh| { + !pick::hit_test::mesh_poly_hit( + poly, + crossing, + std::iter::once((handle, mesh, set.instance_transform)), + view_rot, + eye, + bounds, + ) + .is_empty() + }) + }; + let mut sets = indices .iter() - .filter_map(|&index| meshes.get(index as usize)) - .any(|set| { - set.geometry_lods().first().is_some_and(|mesh| { - !pick::hit_test::mesh_poly_hit( - poly, - crossing, - std::iter::once((handle, mesh, set.instance_transform)), - view_rot, - eye, - bounds, - ) - .is_empty() - }) - }); + .filter_map(|&index| meshes.get(index as usize)); + let hit = if crossing { + sets.any(test) + } else { + let mut count = 0; + sets.all(|set| { + count += 1; + test(set) + }) && count > 0 + }; if hit { out.push(handle); } @@ -8854,6 +8987,27 @@ impl Scene { frozen_layers: Option<&HashSet>, annotation_scale_handle: Option, all_visible: bool, + ) -> bool { + let layer = self.document.layers.get(&e.common().layer); + self.resident_entity_visible_with_layer( + e, + block_handle, + frozen_layers, + annotation_scale_handle, + all_visible, + layer, + ) + } + + /// Visibility predicate with the layer resolved by the caller. + fn resident_entity_visible_with_layer( + &self, + e: &EntityType, + block_handle: Handle, + frozen_layers: Option<&HashSet>, + annotation_scale_handle: Option, + all_visible: bool, + layer: Option<&acadrust::tables::layer::Layer>, ) -> bool { let c = e.common(); if c.invisible { @@ -8867,7 +9021,6 @@ impl Scene { if matches!(e, EntityType::Block(_) | EntityType::BlockEnd(_)) { return false; } - let layer = self.document.layers.get(&c.layer); if layer .map(|l| l.flags.off || l.flags.frozen) .unwrap_or(false) @@ -8894,8 +9047,8 @@ impl Scene { self.belongs_to_visible_block(c.handle, c.owner_handle, block_handle) } - fn wires_for_block_culled( - &self, + fn wires_for_block_culled<'doc>( + &'doc self, block_handle: Handle, view_aabb: Option<[f32; 4]>, wpp: Option, @@ -8949,13 +9102,25 @@ impl Scene { // Visibility test reused by both paths below — and by the resident // incremental patch, so a changed entity is included/excluded exactly as // a from-scratch build would (no divergence). - let visibility_ok = |e: &EntityType| { - self.resident_entity_visible( + // Resolve each distinct layer name once for this candidate pass. + type LayerRef<'a> = Option<&'a acadrust::tables::layer::Layer>; + let layer_of: RefCell>> = + RefCell::new(rustc_hash::FxHashMap::default()); + let visibility_ok = |e: &'doc EntityType| { + let name = e.common().layer.as_str(); + let layer = match layer_of.borrow_mut().entry(name) { + std::collections::hash_map::Entry::Occupied(hit) => *hit.get(), + std::collections::hash_map::Entry::Vacant(slot) => { + *slot.insert(self.document.layers.get(name)) + } + }; + self.resident_entity_visible_with_layer( e, block_handle, frozen_layers, annotation_scale_handle, all_visible, + layer, ) }; @@ -8967,6 +9132,9 @@ impl Scene { // version of this diagnostic report a constant. let visible_path: &str; let mut visible_candidates: usize; + // PERF-only breakdown for index maintenance and handle resolution. + let mut visible_index_ms = 0.0f64; + let mut visible_probe_ms = 0.0f64; // Phase 2.1 — quadtree-driven candidate selection. When a view // AABB exists (Model layout with a settled camera), only iterate @@ -9034,16 +9202,12 @@ impl Scene { .filter(|e| visibility_ok(e)) .collect() } else { - // Ask the per-epoch membership index for this block's candidates - // instead of testing every entity in the document. For a paper - // sheet holding 43 entities in a 1.17 M-entity drawing that is the - // difference between 313 ms and nothing measurable. - // - // The index is filled by walking `document.entities()`, so each - // block's list is already in document order and the wires come out - // in exactly the order the full scan produced. + // The per-epoch index preserves document order for each block. visible_path = "block-index"; + // Time index maintenance separately from the candidate loop. + let t_members = perf.then(iced::time::Instant::now); let members = self.block_members(); + visible_index_ms = crate::perf::elapsed_ms(t_members); let (_, by_block) = &*members; let claimed = by_block.get(&block_handle).map(Vec::as_slice).unwrap_or(&[]); visible_candidates = claimed.len(); @@ -9055,6 +9219,18 @@ impl Scene { } } } + if perf { + // PERF-only lower bound for warm handle resolution. + let t_probe = iced::time::Instant::now(); + let mut resolved = 0usize; + for &handle in claimed { + if self.document.get_entity(handle).is_some() { + resolved += 1; + } + } + std::hint::black_box(resolved); + visible_probe_ms = t_probe.elapsed().as_secs_f64() * 1000.0; + } out }; @@ -9123,7 +9299,7 @@ impl Scene { static EN: std::sync::OnceLock = std::sync::OnceLock::new(); *EN.get_or_init(|| std::env::var("OCS_NO_RESIDENT_MEMO").is_err()) } - let resident = base_ok && view_aabb.is_none() && wpp.is_none() && resident_memo_enabled(); + let resident = base_ok && view_aabb.is_none() && resident_memo_enabled(); let memo_active = base_ok && (view_aabb.is_some() || resident); // Reported in one line below. `tess_ms` / `memo_misses` are assigned by // both branches; the rest exist only on the memo path and stay zero. @@ -9144,7 +9320,10 @@ impl Scene { let guard = { let mut g: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |x: u64| g = g.rotate_left(13) ^ x; - mix(wpp.map(|w| w.to_bits() as u64).unwrap_or(u64::MAX)); + // wpp removed from guard: GPU analytical rendering handles + // circles/arcs/ellipses, Point ignores wpp, and Light (the + // only remaining wpp consumer) is rare enough that its stale + // glyphs don't justify clearing every memoized entity on zoom. if let Some(v) = view_aabb { for c in v { mix(c.to_bits() as u64); @@ -9296,7 +9475,8 @@ impl Scene { "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} colors={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ entities={} memo_hit={} memo_miss={} wires={} memo={} visible_path={} candidates={} \ -guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", +guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={} \ +vis_index={:.1} visible_probe={:.1}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, @@ -9323,6 +9503,8 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", annotation_scale_handle.map(|h| h.value()).unwrap_or(0), all_visible, crate::scene::text::sdf_atlas::generation(), + visible_index_ms, + visible_probe_ms, ); } wires @@ -9349,15 +9531,50 @@ guard={:016x} guard_stale={} avp={} anno={:.4} anno_h={} all_vis={} sdf_gen={}", std::cell::Ref::map(self.unindexable_cache.borrow(), |c| c.as_ref().unwrap()) } - /// Resolve owner handles and block-record membership once per geometry epoch. + /// Resolve block membership once per geometry epoch. A single addition is + /// appended safely; any other delta rebuilds the document-ordered index. fn block_members(&self) -> std::cell::Ref<'_, BlockMembers> { + let mut cached_epoch = None; { let cache = self.block_members_cache.borrow(); - if cache.as_ref().is_some_and(|(epoch, _)| *epoch == self.geometry_epoch) { - drop(cache); - return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { - c.as_ref().unwrap() + if let Some((epoch, _)) = cache.as_ref() { + if *epoch == self.geometry_epoch { + drop(cache); + return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { + c.as_ref().unwrap() + }); + } + cached_epoch = Some(*epoch); + } + } + + if let Some(since) = cached_epoch { + let lone_add = self.replay_since(since).filter(|deltas| { + deltas.len() == 1 && deltas[0].1 == ChangeKind::Added + }); + if let Some(deltas) = lone_add { + let handle = deltas[0].0; + // Resolve the owner before touching the cache: both lookups + // borrow, and `entity_block_map` is its own `RefCell`. + let owner = self.document.get_entity(handle).and_then(|entity| { + let owner = entity.common().owner_handle; + if !owner.is_null() { + Some(owner) + } else { + self.entity_block_map().get(&handle).copied() + } }); + let mut cache = self.block_members_cache.borrow_mut(); + if let Some((epoch, members)) = cache.as_mut() { + if let Some(owner) = owner { + members.entry(owner).or_default().push(handle); + } + *epoch = self.geometry_epoch; + drop(cache); + return std::cell::Ref::map(self.block_members_cache.borrow(), |c| { + c.as_ref().unwrap() + }); + } } } let mut members: HashMap> = HashMap::default(); @@ -10532,6 +10749,116 @@ mod journal_tests { // Differential oracle: the incrementally-patched entity index must always // equal a from-scratch rebuild after any add / move / erase. + /// A line has tangent geometry but does not feed the analytical uploads. + #[test] + fn the_partition_membership_test_separates_lines_from_curves() { + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + use crate::scene::pipeline::wire_arena::feeds_analytical_uploads; + + let mut scene = Scene::new(); + let feeds = |scene: &Scene, handle: Handle| { + let entity = scene.document.get_entity(handle).unwrap().clone(); + scene + .tessellate_one(&entity) + .iter() + .any(feeds_analytical_uploads) + }; + + let line = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(10.0, 5.0, 0.0), + ))); + assert!( + !feeds(&scene, line), + "a drawn line must not force the partition walk", + ); + + let mut circle = Circle::new(); + circle.radius = 4.0; + let circle = scene.add_entity(EntityType::Circle(circle)); + assert!( + feeds(&scene, circle), + "a drawn circle feeds the analytical uploads and must force the walk", + ); + } + + /// Incremental additions preserve existing depths; full rebuilds invalidate them. + #[test] + fn adding_an_entity_leaves_existing_draw_depths_alone() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + fn add(scene: &mut Scene, x: f64) -> Handle { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + } + let mut scene = Scene::new(); + let first = add(&mut scene, 0.0); + let second = add(&mut scene, 1.0); + let before = scene.draw_depth_map(); + let generation = scene.draw_depth_generation(); + + add(&mut scene, 2.0); + let after = scene.draw_depth_map(); + assert_eq!( + scene.draw_depth_generation(), + generation, + "an incremental add must not invalidate baked depths", + ); + for handle in [first, second] { + assert_eq!( + after.get(&handle.value()), + before.get(&handle.value()), + "an existing entity's depth must not move when another is added", + ); + } + + // A rebuild from scratch reassigns labels, so it has to say so. + scene.draw_depth_cache.borrow_mut().take(); + let _ = scene.draw_depth_map(); + assert_ne!( + scene.draw_depth_generation(), + generation, + "a full rebuild must invalidate anything holding a baked depth", + ); + } + + /// A folded addition must match the full document-order scan. + #[test] + fn folding_one_addition_matches_the_full_scan() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + fn add(scene: &mut Scene, x: f64) -> Handle { + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(x, 0.0, 0.0), + Vector3::new(x + 1.0, 0.0, 0.0), + ))) + } + add(&mut scene, 0.0); + add(&mut scene, 1.0); + // Build the index, then add one more so the next call folds. + let block = scene.model_space_block_handle(); + let before = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + let third = add(&mut scene, 2.0); + let folded = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + + scene.block_members_cache.borrow_mut().take(); + let rebuilt = scene.block_members().1.get(&block).cloned().unwrap_or_default(); + + assert_eq!(folded, rebuilt, "the folded index must equal a full rebuild"); + assert_eq!( + folded.last(), + Some(&third), + "an appended entity belongs at the end of its block's list", + ); + assert_eq!(folded.len(), before.len() + 1); + } + #[test] fn block_member_index_matches_the_full_scan() { use acadrust::entities::Line; @@ -10584,6 +10911,152 @@ mod journal_tests { assert_eq!(indexed(&s, block), scanned(&s, block), "after modify"); } + /// Raw layer spellings used as memo keys must resolve through the + /// case-insensitive table. + #[test] + fn the_layer_memo_respects_case_insensitive_layer_names() { + use acadrust::entities::Line; + use acadrust::tables::layer::Layer; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let mut hidden = Layer::new("Walls"); + hidden.flags.off = true; + scene.document.layers.add_or_replace(hidden); + + let mut on_layer = |layer: &str| { + let mut line = Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ); + line.common.layer = layer.to_string(); + scene.add_entity(EntityType::Line(line)) + }; + // Spellings of the switched-off layer must all remain hidden. + on_layer("Walls"); + on_layer("WALLS"); + on_layer("walls"); + on_layer("Doors"); + + let block = scene.model_space_block_handle(); + let visible: Vec<_> = scene + .document + .entities() + .filter(|e| scene.resident_entity_visible(e, block, None, None, true)) + .map(|e| e.common().layer.clone()) + .collect(); + assert_eq!( + visible, + vec!["Doors".to_string()], + "every spelling of a switched-off layer must be hidden", + ); + + // Each spelling resolves to the same table entry. + let resolved: Vec<_> = ["Walls", "WALLS", "walls"] + .iter() + .map(|name| scene.document.layers.get(name).map(|l| l.name.clone())) + .collect(); + assert_eq!( + resolved, + vec![Some("Walls".to_string()); 3], + "the table is case-insensitive, which is what lets the memo key on \ + the raw name", + ); + } + + /// The loader's handed-over draw depths must equal a local rebuild. + #[test] + fn the_handed_over_draw_depth_map_matches_a_recompute() { + use acadrust::entities::{Circle, Line}; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + scene.add_entity(EntityType::Circle(Circle::new())); + scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(2.0, 0.0, 0.0), + Vector3::new(3.0, 0.0, 0.0), + ))); + + // Capture the map the loader would hand over. + let expected = scene.draw_depth_map(); + let handed_over = scene + .draw_depth_cache + .borrow_mut() + .take() + .expect("the map was just computed, so it is cached"); + + // Install it under the receiving scene's epoch. + scene.bump_geometry(); + scene.install_prepared_open_geometry(PreparedOpenGeometry { + wires: Arc::new(Vec::new()), + interaction_index: None, + tess_memo: HashMap::default(), + tess_guard: 0, + block_defns: Vec::new(), + draw_depths: Some(handed_over), + resident_layout: None, + }); + assert_eq!( + *scene.draw_depth_map(), + *expected, + "the handed-over map must be served as-is", + ); + + // A local rebuild must agree. + *scene.draw_depth_cache.borrow_mut() = None; + assert_eq!( + *scene.draw_depth_map(), + *expected, + "a recompute must agree with what was handed over", + ); + } + + #[test] + fn prepared_open_geometry_keeps_layout_for_the_first_edit_patch() { + use acadrust::entities::Line; + use acadrust::types::Vector3; + + fn line(y: f64) -> EntityType { + EntityType::Line(Line::from_points( + Vector3::new(0.0, y, 0.0), + Vector3::new(10.0, y, 0.0), + )) + } + + let mut document = CadDocument::new(); + document.add_entity(line(0.0)).unwrap(); + let caches = build_derived_caches_impl(&document, None, None); + let model_bg = [0.0, 0.0, 0.0, 1.0]; + let (document, prepared) = prepare_open_geometry(document, &caches, model_bg); + assert!(prepared.resident_layout.is_some()); + + let mut scene = Scene::new(); + scene.document = document; + scene.local_extent_max = caches.local_extent_max; + scene.local_center = caches.local_center; + scene.bg_color = model_bg; + scene.current_layout = "Model".to_string(); + let scale = scene.document.header.annotation_scale_value; + let unit_factor = scene.annotation_scale_unit_factor(); + scene.annotation_scale = if scale > 1e-9 { + ((1.0 / scale) / unit_factor) as f32 + } else { + (1.0 / unit_factor) as f32 + }; + scene.install_prepared_open_geometry(prepared); + + let hits = scene.resident_patch_hits.get(); + scene.add_entity(line(1.0)); + let wires = scene.model_tile_wires_arc(0, &Camera::default(), 1.0, 1.0); + drop(wires); + + assert_eq!(scene.resident_patch_hits.get(), hits + 1); + } + #[test] fn entity_index_incremental_matches_full_rebuild() { use acadrust::entities::Line; @@ -11288,4 +11761,318 @@ mod layout_cache_tests { assert!(!source.is_empty()); } } + + #[test] + fn resident_wires_are_zoom_independent_and_gpu_analytical() { + let mut s = Scene::new(); + let mut circle = acadrust::entities::Circle::default(); + circle.radius = 100.0; + let handle = s.add_entity(EntityType::Circle(circle)); + + // Far camera: distance is large + let mut cam_far = Camera::default(); + cam_far.distance = 1000.0; + + // Close camera: distance is small + let mut cam_close = Camera::default(); + cam_close.distance = 1.0; + + let wires_far = s.model_tile_wires_arc(0, &cam_far, 1.0, 1000.0); + let circle_wire_far = wires_far + .iter() + .find(|w| w.name == handle.value().to_string()) + .unwrap(); + + let wires_close = s.model_tile_wires_arc(0, &cam_close, 1.0, 1000.0); + + // Zooming must reuse the exact same resident wire set (zero re-tessellation) + assert!( + Arc::ptr_eq(&wires_far, &wires_close), + "Resident wires must be identical Arc across zoom levels" + ); + // Circle must carry TangentGeom for GPU analytical rendering + assert_eq!(circle_wire_far.tangent_geoms.len(), 1); + assert!(matches!( + circle_wire_far.tangent_geoms[0], + crate::scene::model::wire_model::TangentGeom::PlanarCircle { .. } + | crate::scene::model::wire_model::TangentGeom::Circle { .. } + )); + } + + #[test] + fn block_circles_and_arcs_extract_as_analytical_gpu_instances() { + let mut s = Scene::new(); + // Create initial entities + let mut circle = acadrust::entities::Circle::default(); + circle.radius = 50.0; + let c_h = s.add_entity(EntityType::Circle(circle)); + + let mut arc = acadrust::entities::Arc::default(); + arc.radius = 25.0; + arc.start_angle = 0.0; + arc.end_angle = 3.14159; + let a_h = s.add_entity(EntityType::Arc(arc)); + + let line = acadrust::entities::Line::from_points( + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(100.0, 100.0, 0.0), + ); + let l_h = s.add_entity(EntityType::Line(line)); + + // Create block and place insert at (200, 300, 0) + let xform = acadrust::types::Transform::from_translation(acadrust::types::Vector3::new(200.0, 300.0, 0.0)); + let _ = s.create_block_from_entities( + &[c_h, a_h, l_h], + "TEST_ANALYTICAL_BLOCK", + &acadrust::types::Transform::identity(), + &xform, + ).unwrap(); + + let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); + let depths = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); + + // Both the circle and the arc inside the block must be extracted as analytical GPU circle instances! + assert!( + partitioned.circle_instances.len() >= 2, + "Expected at least 2 analytical circle instances from block, got {}", + partitioned.circle_instances.len() + ); + } + + #[test] + fn single_bulge_polyline_extracts_as_analytical_gpu_arc() { + let mut s = Scene::new(); + let mut pline = acadrust::entities::LwPolyline::new(); + pline.vertices = vec![ + acadrust::entities::LwVertex { + location: acadrust::types::Vector2::new(0.0, 0.0), + bulge: 1.0, // semicircle + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + acadrust::entities::LwVertex { + location: acadrust::types::Vector2::new(100.0, 0.0), + bulge: 0.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + ]; + let _ = s.add_entity(EntityType::LwPolyline(pline)); + + let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); + let depths = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); + + assert_eq!( + partitioned.circle_instances.len(), + 1, + "Single bulge arc polyline must be extracted as analytical GPU arc instance" + ); + } + + #[test] + fn multi_bulge_polyline_extracts_as_analytical_gpu_arcs() { + let mut s = Scene::new(); + let mut pline = acadrust::entities::LwPolyline::new(); + // A circle represented as a closed 2-vertex polyline with two semicircle bulges (standard CAD polyline circle) + pline.is_closed = true; + pline.vertices = vec![ + acadrust::entities::LwVertex { + location: acadrust::types::Vector2::new(0.0, 0.0), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + acadrust::entities::LwVertex { + location: acadrust::types::Vector2::new(100.0, 0.0), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + let _ = s.add_entity(EntityType::LwPolyline(pline)); + + let wires = s.model_tile_wires_arc(0, &Camera::default(), 1.0, 1000.0); + let depths = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); + + assert_eq!( + partitioned.circle_instances.len(), + 2, + "Multi-bulge closed polyline circle must be extracted as 2 analytical GPU arc instances" + ); + assert_eq!( + partitioned.regular.len(), + 0, + "Multi-bulge pure arc polyline must not be sent to regular line arena" + ); + } + + #[test] + fn mixed_bulge_polyline_splits_into_analytical_arcs_and_straight_lines() { + use acadrust::entities::{LwPolyline, LwVertex}; + use acadrust::types::Vector2; + + let mut scene = Scene::new(); + let mut pline = LwPolyline::new(); + // Rounded slot: 2 straight lines and 2 semicircular ends (bulge = 1.0) + // Vertex 0: (0, 0), bulge 1.0 (arc from 0,0 to 0,10) + // Vertex 1: (0, 10), bulge 0.0 (straight from 0,10 to 50,10) + // Vertex 2: (50, 10), bulge 1.0 (arc from 50,10 to 50,0) + // Vertex 3: (50, 0), bulge 0.0 (straight from 50,0 to 0,0) + let v0 = LwVertex::with_bulge(Vector2::new(0.0, 0.0), 1.0); + let v1 = LwVertex::new(Vector2::new(0.0, 10.0)); + let v2 = LwVertex::with_bulge(Vector2::new(50.0, 10.0), 1.0); + let v3 = LwVertex::new(Vector2::new(50.0, 0.0)); + pline.vertices = vec![v0, v1, v2, v3]; + pline.is_closed = true; + + let _ = scene.add_entity(EntityType::LwPolyline(pline)); + + let camera = Camera::default(); + let wires = scene.model_tile_wires_arc(0, &camera, 1.0, 1000.0); + let depths = rustc_hash::FxHashMap::default(); + let partitioned = crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths); + + assert_eq!( + partitioned.circle_instances.len(), + 2, + "Mixed polyline should have its 2 bulge arcs routed to analytical CircleGpu" + ); + assert_eq!( + partitioned.regular.len(), + 1, + "Mixed polyline straight lines should form 1 regular wire" + ); + } + + #[test] + #[ignore = "benchmark"] + fn bench_analytical_rendering() { + use std::time::Instant; + use acadrust::entities::{Arc as AcadArc, Circle, Ellipse, LwPolyline, LwVertex}; + use acadrust::types::{Vector2, Vector3}; + + let n_circles = 5000; + let n_arcs = 5000; + let n_ellipses = 2000; + let n_polylines = 2000; + let total_entities = n_circles + n_arcs + n_ellipses + n_polylines; + + let mut scene = Scene::new(); + for i in 0..n_circles { + let x = (i % 100) as f64 * 50.0; + let y = (i / 100) as f64 * 50.0; + let mut circle = Circle::default(); + circle.center = Vector3::new(x, y, 0.0); + circle.radius = 10.0 + (i % 20) as f64; + scene.add_entity(EntityType::Circle(circle)); + } + + for i in 0..n_arcs { + let x = (i % 100) as f64 * 50.0 + 25.0; + let y = (i / 100) as f64 * 50.0; + let mut arc = AcadArc::default(); + arc.center = Vector3::new(x, y, 0.0); + arc.radius = 15.0; + arc.start_angle = ((i % 8) as f64) * 0.25 * std::f64::consts::PI; + arc.end_angle = arc.start_angle + 0.75 * std::f64::consts::PI; + scene.add_entity(EntityType::Arc(arc)); + } + + for i in 0..n_ellipses { + let x = (i % 50) as f64 * 100.0; + let y = (i / 50) as f64 * 100.0 + 5000.0; + let mut el = Ellipse::default(); + el.center = Vector3::new(x, y, 0.0); + el.major_axis = Vector3::new(20.0, 0.0, 0.0); + el.minor_axis_ratio = 0.5; + el.start_parameter = 0.0; + el.end_parameter = std::f64::consts::TAU; + scene.add_entity(EntityType::Ellipse(el)); + } + + for i in 0..n_polylines { + let x = (i % 50) as f64 * 100.0 + 50.0; + let y = (i / 50) as f64 * 100.0 + 5000.0; + let mut pline = LwPolyline::new(); + pline.is_closed = true; + pline.vertices = vec![ + LwVertex { + location: Vector2::new(x, y), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 0, + }, + LwVertex { + location: Vector2::new(x + 30.0, y), + bulge: 1.0, + start_width: 0.0, + end_width: 0.0, + vertex_id: 1, + }, + ]; + scene.add_entity(EntityType::LwPolyline(pline)); + } + + let cam = Camera::default(); + let t_tess_start = Instant::now(); + let wires = scene.model_tile_wires_arc(0, &cam, 1.0, 1000.0); + let tess_duration = t_tess_start.elapsed(); + + let total_wires = wires.len(); + let mut chord_points = 0usize; + let mut analytical_tangents = 0usize; + for w in wires.iter() { + chord_points += w.points.len(); + analytical_tangents += w.tangent_geoms.len(); + } + + let depths = rustc_hash::FxHashMap::default(); + let t_part_start = Instant::now(); + let iters = 100; + let mut part = None; + for _ in 0..iters { + part = Some(crate::scene::pipeline::wire_arena::partition_wires(&wires, &depths)); + } + let part_duration = t_part_start.elapsed() / (iters as u32); + let p = part.unwrap(); + + // Zoom simulation + let zoom_levels = [0.1f32, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 50.0, 100.0, 1000.0]; + let t_zoom_start = Instant::now(); + let mut dynamic_cam = Camera::default(); + for &zoom in zoom_levels.iter().cycle().take(100) { + dynamic_cam.distance = 1000.0 / zoom; + let _w = scene.model_tile_wires_arc(0, &dynamic_cam, 1.0, 1000.0); + } + let zoom_100_duration = t_zoom_start.elapsed(); + let per_zoom_ms = zoom_100_duration.as_secs_f64() * 1000.0 / 100.0; + + let line_vram_mb = (chord_points * 36) as f64 / (1024.0 * 1024.0); + let inst_vram_mb = (p.circle_instances.len() * 128 + p.ellipse_instances.len() * 144) as f64 / (1024.0 * 1024.0); + + println!("\n=== BENCHMARK REPORT: CURRENT HEAD (WITH GPU ANALYTICAL & OPTIMIZATIONS) ==="); + println!("Curved Entities Tested: {total_entities}"); + println!("Initial Wire Build Time: {:.2?}", tess_duration); + println!("Total Wires: {total_wires}"); + println!("Tessellated Chord Vertices: {chord_points}"); + println!("Analytical Tangent Geometries: {analytical_tangents}"); + println!("Partitioning Time (per frame): {:.3?}", part_duration); + println!("Regular Line Wires: {}", p.regular.len()); + println!("GPU Circle/Arc Instances: {}", p.circle_instances.len()); + println!("GPU Ellipse Instances: {}", p.ellipse_instances.len()); + println!("Line Arena VRAM (chord lines): {:.2} MB", line_vram_mb); + println!("GPU Analytical VRAM: {:.2} MB", inst_vram_mb); + println!("VRAM Reduction Ratio: {:.1}x", if inst_vram_mb > 0.0 { line_vram_mb / inst_vram_mb } else { 0.0 }); + println!("100 Camera Zoom Navigations: {:.2?}", zoom_100_duration); + println!("Zoom Frame Overhead: {:.3} ms ({:.0} FPS)", per_zoom_ms, 1000.0 / per_zoom_ms.max(0.001)); + println!("============================================================================\n"); + } } diff --git a/src/scene/model/mesh_model.rs b/src/scene/model/mesh_model.rs index 628b7c408..f59f91d77 100644 --- a/src/scene/model/mesh_model.rs +++ b/src/scene/model/mesh_model.rs @@ -55,6 +55,44 @@ pub struct MeshMetrics { pub surface_area: f64, pub volume: f64, pub centroid: [f64; 3], + pub moment_of_inertia: [f64; 3], + pub principal_directions: [f64; 9], + pub principal_moments: [f64; 3], + pub product_of_inertia: [f64; 3], + pub radii_of_gyration: [f64; 3], +} + +impl MeshMetrics { + fn apply_mass_properties(&mut self, properties: cadkernel::brep::MassProperties) { + self.volume = properties.volume; + self.centroid = properties.centroid; + self.moment_of_inertia = properties.moment_of_inertia; + self.principal_directions = properties.principal_directions; + self.principal_moments = properties.principal_moments; + self.product_of_inertia = properties.product_of_inertia; + self.radii_of_gyration = properties.radii_of_gyration; + } + + pub fn translate(&mut self, delta: [f64; 3]) { + if self.volume > 1e-18 { + self.apply_mass_properties( + cadkernel::brep::MassProperties { + volume: self.volume, + centroid: self.centroid, + moment_of_inertia: self.moment_of_inertia, + principal_directions: self.principal_directions, + principal_moments: self.principal_moments, + product_of_inertia: self.product_of_inertia, + radii_of_gyration: self.radii_of_gyration, + } + .translated(delta), + ); + } else { + for axis in 0..3 { + self.centroid[axis] += delta[axis]; + } + } + } } #[derive(Clone, Debug)] @@ -146,74 +184,52 @@ fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics { let Some(mesh) = lods.iter().find(|mesh| !mesh.indices.is_empty()) else { return MeshMetrics::default(); }; - let point = |index: u32| { - let index = index as usize; - let high = mesh.verts.get(index).copied().unwrap_or([0.0; 3]); - let low = mesh.verts_low.get(index).copied().unwrap_or([0.0; 3]); - [ - high[0] as f64 + low[0] as f64, - high[1] as f64 + low[1] as f64, - high[2] as f64 + low[2] as f64, - ] + let kernel_mesh = cadkernel::brep::Mesh { + positions: mesh + .verts + .iter() + .enumerate() + .map(|(index, high)| { + let low = mesh.verts_low.get(index).copied().unwrap_or([0.0; 3]); + [ + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + ] + }) + .collect(), + normals: Vec::new(), + triangles: mesh + .indices + .chunks_exact(3) + .map(|triangle| { + [ + triangle[0] as usize, + triangle[1] as usize, + triangle[2] as usize, + ] + }) + .collect(), }; - let mut area = 0.0; - let mut area_centroid_numerator = [0.0; 3]; - let mut signed_volume = 0.0; - let mut centroid_numerator = [0.0; 3]; - for triangle in mesh.indices.chunks_exact(3) { - let a = point(triangle[0]); - let b = point(triangle[1]); - let c = point(triangle[2]); - let ab = [b[0] - a[0], b[1] - a[1], b[2] - a[2]]; - let ac = [c[0] - a[0], c[1] - a[1], c[2] - a[2]]; - let cross = [ - ab[1] * ac[2] - ab[2] * ac[1], - ab[2] * ac[0] - ab[0] * ac[2], - ab[0] * ac[1] - ab[1] * ac[0], - ]; - let triangle_area = 0.5 - * (cross[0] * cross[0] + cross[1] * cross[1] + cross[2] * cross[2]) - .sqrt(); - area += triangle_area; - for axis in 0..3 { - area_centroid_numerator[axis] += - triangle_area * (a[axis] + b[axis] + c[axis]) / 3.0; - } - let tetra = ( - a[0] * (b[1] * c[2] - b[2] * c[1]) - - a[1] * (b[0] * c[2] - b[2] * c[0]) - + a[2] * (b[0] * c[1] - b[1] * c[0]) - ) / 6.0; - signed_volume += tetra; - for axis in 0..3 { - centroid_numerator[axis] += tetra * (a[axis] + b[axis] + c[axis]) / 4.0; - } - } - let centroid = if signed_volume.abs() > 1e-18 { - [ - centroid_numerator[0] / signed_volume, - centroid_numerator[1] / signed_volume, - centroid_numerator[2] / signed_volume, - ] - } else if area > 1e-18 { - [ - area_centroid_numerator[0] / area, - area_centroid_numerator[1] / area, - area_centroid_numerator[2] / area, - ] - } else { - [0.0; 3] - }; - MeshMetrics { + let surface = kernel_mesh.surface_properties(); + let mut metrics = MeshMetrics { vertices: mesh.verts.len(), triangles: mesh.indices.len() / 3, - surface_area: area, - volume: signed_volume.abs(), - centroid, + surface_area: surface.map_or(0.0, |value| value.0), + centroid: surface.map_or([0.0; 3], |value| value.1), + ..MeshMetrics::default() + }; + if let Some(properties) = kernel_mesh.inertial_properties() { + metrics.apply_mass_properties(properties); } + metrics } impl MeshLodSet { + pub fn apply_mass_properties(&mut self, properties: cadkernel::brep::MassProperties) { + self.metrics.apply_mass_properties(properties); + } + /// Build a set from its LODs, computing the 3D AABB. pub fn from_lods(lods: Vec) -> Self { let (world_aabb, z_aabb) = compute_mesh_aabb(&lods); diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index 37293ac70..1718313ee 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -1,12 +1,13 @@ use acadrust::entities::{EmbeddedEntity, Solid3D}; use acadrust::objects::{ - DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryCone, - SolidHistoryCylinder, SolidHistoryLoft, SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, + DynamicBlockData, ObjectType, SolidHistoryBox, SolidHistoryBrep, SolidHistoryChamfer, + SolidHistoryCone, SolidHistoryCylinder, SolidHistoryFillet, SolidHistoryLoft, + SolidHistoryLoftParameters, SolidHistoryNodeBase, SolidHistoryOperation, SolidHistoryPyramid, SolidHistoryRevolve, SolidHistorySphere, SolidHistorySweep, SolidHistoryTorus, }; use acadrust::EntityType; -use cadkernel::brep::Body; +use cadkernel::brep::{Body, Surface}; use crate::command::EntityTransform; use crate::entities::traits::EntityTypeOps; @@ -32,6 +33,9 @@ pub const GRIP_SWEEP_PATH_FIRST: usize = 30_000; pub const GRIP_LOFT_SECTION_FIRST: usize = 40_000; pub const GRIP_REVOLVE_PROFILE_FIRST: usize = 50_000; pub const GRIP_REVOLVE_AXIS: usize = 10_013; +pub const GRIP_FILLET_RADIUS: usize = 10_014; +pub const GRIP_CHAMFER_DISTANCE1: usize = 10_015; +pub const GRIP_CHAMFER_DISTANCE2: usize = 10_016; pub const GRIP_BOX_CORNER_FIRST: usize = 10_100; pub const GRIP_BOX_FACE_X_MIN: usize = 10_110; pub const GRIP_BOX_FACE_X_MAX: usize = 10_111; @@ -197,7 +201,7 @@ pub fn has_specialized_primitive_properties( handle: acadrust::Handle, ) -> bool { matches!( - document.solid_history_operation(handle), + primitive_property_operation(document, handle).as_ref(), Some( SolidHistoryOperation::Box(_) | SolidHistoryOperation::Wedge(_) @@ -214,6 +218,32 @@ pub fn has_specialized_primitive_properties( ) } +/// Return the primitive that owns the public Geometry rows. Later edge +/// operations refine that primitive without replacing its editable type, +/// position, or dimensions. +pub fn primitive_property_operation( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> Option { + document + .solid_history_operations(handle) + .and_then(|operations| operations.into_iter().next()) + .or_else(|| document.solid_history_operation(handle).cloned()) +} + +/// Solid history results whose public Properties palette is fully described by +/// the common entity rows plus [`primitive_properties`]. A generic B-rep has +/// no stable primitive dimensions to expose, but it still uses the compact +/// Solid History palette rather than the internal ACIS/cache diagnostics. +pub fn has_compact_solid_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> bool { + document + .get_entity(handle) + .is_some_and(|entity| matches!(entity, acadrust::EntityType::Solid3D(_))) +} + pub fn reference_point(operation: &SolidHistoryOperation) -> Option { match operation { SolidHistoryOperation::Box(value) | SolidHistoryOperation::Wedge(value) => world_point( @@ -975,6 +1005,77 @@ fn cone_properties( ] } +fn brep_properties( + document: &acadrust::CadDocument, + handle: acadrust::Handle, +) -> Vec { + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); + let mut geometry = vec![Property { + label: t!("Solid type").into_owned(), + field: "solid_history_type", + value: PropValue::ReadOnly(t!("3D Solid").into_owned()), + }]; + if let Some(position) = document + .solid_history_operation(handle) + .and_then(|operation| match operation { + SolidHistoryOperation::Brep(value) => world_point(value.base.transform, [0.0; 3]), + _ => None, + }) + { + geometry.extend([ + crate::entities::common::edit_prop( + t!("Position X").as_ref(), + PROP_POSITION_X, + position.x, + ), + crate::entities::common::edit_prop( + t!("Position Y").as_ref(), + PROP_POSITION_Y, + position.y, + ), + crate::entities::common::edit_prop( + t!("Position Z").as_ref(), + PROP_POSITION_Z, + position.z, + ), + ]); + } + vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: geometry, + }, + PropSection { + title: t!("Solid History").into_owned(), + props: vec![ + Property { + label: t!("History").into_owned(), + field: PROP_HISTORY, + value: PropValue::Choice { + selected: if record_history { "Record" } else { "None" }.to_string(), + options: vec!["None".to_string(), "Record".to_string()], + }, + }, + Property { + label: t!("Show History").into_owned(), + field: PROP_SHOW_HISTORY, + value: if show_history_editable { + PropValue::Choice { + selected: if show_history { "Yes" } else { "No" }.to_string(), + options: vec!["No".to_string(), "Yes".to_string()], + } + } else { + PropValue::ReadOnly(if show_history { "Yes" } else { "No" }.to_string()) + }, + }, + ], + }, + ] +} + fn cylinder_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -1390,10 +1491,10 @@ pub fn primitive_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> Vec { - let Some(operation) = document.solid_history_operation(handle) else { - return Vec::new(); + let Some(operation) = primitive_property_operation(document, handle) else { + return brep_properties(document, handle); }; - match operation { + match &operation { SolidHistoryOperation::Box(value) => { rectangular_properties(document, handle, value, "Box") } @@ -1411,7 +1512,8 @@ pub fn primitive_properties( extrusion_properties(document, handle, value) } SolidHistoryOperation::Revolve(value) => revolve_properties(document, handle, value), - _ => Vec::new(), + SolidHistoryOperation::Brep(_) => brep_properties(document, handle), + _ => brep_properties(document, handle), } } @@ -2137,11 +2239,50 @@ fn set_pyramid_sides(value: &mut SolidHistoryPyramid, sides: i32) -> bool { true } +fn apply_brep_position_property( + value: &mut SolidHistoryBrep, + field: &str, + text: &str, +) -> Option { + let axis = match field { + PROP_POSITION_X => 0, + PROP_POSITION_Y => 1, + PROP_POSITION_Z => 2, + _ => return None, + }; + let Some(target) = crate::entities::common::parse_length(text) else { + return Some(false); + }; + if !target.is_finite() { + return Some(false); + } + let Some(current) = matrix(value.base.transform) else { + return Some(false); + }; + let position = current.transform_point3(glam::DVec3::ZERO); + let mut next = position; + next[axis] = target; + if next == position { + return Some(true); + } + let updated = glam::DMat4::from_translation(next - position) * current; + if !updated.is_finite() || updated.determinant().abs() <= 1e-12 { + return Some(false); + } + value.base.transform = updated.to_cols_array(); + Some(true) +} + pub fn apply_primitive_property( operation: &mut SolidHistoryOperation, field: &str, value: &str, ) -> bool { + if let SolidHistoryOperation::Brep(brep_value) = operation { + if let Some(applied) = apply_brep_position_property(brep_value, field, value) { + return applied; + } + } if let SolidHistoryOperation::Box(rectangular_value) | SolidHistoryOperation::Wedge(rectangular_value) = operation { @@ -3037,6 +3178,149 @@ fn grip( } } +pub fn fillet_radius_grip(body: &Body, radius: f64) -> Option { + if !radius.is_finite() || radius <= 0.0 { + return None; + } + for (face_key, face) in body.faces.iter() { + let Some(Surface::Cylinder(cylinder)) = body.surfaces.get(face.surface) else { + continue; + }; + if (cylinder.radius.abs() - radius).abs() > radius.max(1.0) * 1.0e-7 { + continue; + } + let axis = glam::DVec3::from_array(cylinder.base.normal()?).try_normalize()?; + let origin = glam::DVec3::from_array(cylinder.base.origin); + let points = body + .face_coedges(face_key) + .into_iter() + .filter_map(|coedge| body.coedges.get(coedge)) + .filter_map(|coedge| body.edges.get(coedge.edge)) + .flat_map(|edge| [edge.start, edge.end]) + .filter_map(|vertex| body.vertices.get(vertex)) + .map(|vertex| glam::DVec3::from_array(vertex.point)) + .collect::>(); + let minimum = points + .iter() + .map(|point| (*point - origin).dot(axis)) + .reduce(f64::min)?; + let maximum = points + .iter() + .map(|point| (*point - origin).dot(axis)) + .reduce(f64::max)?; + let center = origin + axis * ((minimum + maximum) * 0.5); + let radial = points + .iter() + .map(|point| *point - center - axis * (*point - center).dot(axis)) + .find_map(|radial| radial.try_normalize())?; + return Some(grip( + GRIP_FILLET_RADIUS, + center + radial * radius, + GripShape::Square, + Some(radial), + )); + } + None +} + +pub fn chamfer_distance_grips( + document: &acadrust::CadDocument, + handle: acadrust::Handle, + value: &SolidHistoryChamfer, +) -> Vec { + let Some(operations) = document.solid_history_operations(handle) else { + return Vec::new(); + }; + let Some(chamfer_index) = operations + .iter() + .rposition(|operation| matches!(operation, SolidHistoryOperation::Chamfer(_))) + else { + return Vec::new(); + }; + let Ok(source) = cadkernel::acis::rebuild_history(&operations[..chamfer_index]) else { + return Vec::new(); + }; + let edges = source.edge_keys().collect::>(); + let faces = source.face_keys().collect::>(); + let Some(edge) = value + .edges + .first() + .and_then(|ordinal| usize::try_from(*ordinal).ok()) + .and_then(|ordinal| edges.get(ordinal).copied()) + .and_then(|edge| source.edges.get(edge)) + else { + return Vec::new(); + }; + let Some(base_face) = usize::try_from(value.base_face) + .ok() + .and_then(|ordinal| faces.get(ordinal).copied()) + else { + return Vec::new(); + }; + let adjacent = edge + .coedges + .iter() + .filter_map(|coedge| source.coedges.get(*coedge)) + .filter_map(|coedge| source.loops.get(coedge.owner)) + .map(|edge_loop| edge_loop.owner) + .collect::>(); + let Some(other_face) = adjacent.iter().copied().find(|face| *face != base_face) else { + return Vec::new(); + }; + let outward = |face_key| { + let face = source.faces.get(face_key)?; + let Surface::Plane(plane) = source.surfaces.get(face.surface)? else { + return None; + }; + let normal = glam::DVec3::from_array(plane.normal()?).try_normalize()?; + Some(if face.forward { normal } else { -normal }) + }; + let Some(base_normal) = outward(base_face) else { + return Vec::new(); + }; + let Some(other_normal) = outward(other_face) else { + return Vec::new(); + }; + let dot = base_normal.dot(other_normal); + let Some(base_inward) = (-(other_normal - base_normal * dot)).try_normalize() else { + return Vec::new(); + }; + let Some(other_inward) = (-(base_normal - other_normal * dot)).try_normalize() else { + return Vec::new(); + }; + let Some(start) = source.vertices.get(edge.start) else { + return Vec::new(); + }; + let Some(end) = source.vertices.get(edge.end) else { + return Vec::new(); + }; + let midpoint = + (glam::DVec3::from_array(start.point) + glam::DVec3::from_array(end.point)) * 0.5; + [ + ( + GRIP_CHAMFER_DISTANCE1, + value.base_distance, + base_inward, + ), + ( + GRIP_CHAMFER_DISTANCE2, + value.other_distance, + other_inward, + ), + ] + .into_iter() + .filter(|(_, distance, _)| distance.is_finite() && *distance > 0.0) + .map(|(id, distance, axis)| { + grip( + id, + midpoint + axis * distance, + GripShape::Square, + Some(axis), + ) + }) + .collect() +} + pub fn primitive_grips( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -3044,6 +3328,21 @@ pub fn primitive_grips( let Some(operation) = document.solid_history_operation(handle) else { return Vec::new(); }; + if let SolidHistoryOperation::Fillet(value) = operation { + let Some(radius) = value.radii.first().copied() else { + return Vec::new(); + }; + let Some(EntityType::Solid3D(solid)) = document.get_entity(handle) else { + return Vec::new(); + }; + return crate::scene::convert::solid3d_tess::kernel_body(solid) + .and_then(|body| fillet_radius_grip(&body, radius)) + .into_iter() + .collect(); + } + if let SolidHistoryOperation::Chamfer(value) = operation { + return chamfer_distance_grips(document, handle, value); + } let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3664,3 +3963,32 @@ pub fn brep_op(body: &Body) -> SolidHistoryOperation { ..SolidHistoryBrep::default() }) } + +pub fn fillet_op(edges: Vec, radius: f64) -> SolidHistoryOperation { + SolidHistoryOperation::Fillet(SolidHistoryFillet { + base: base(glam::DMat4::IDENTITY.to_cols_array()), + operation_major: 1, + method: 0, + edges, + radii: vec![radius], + ..SolidHistoryFillet::default() + }) +} + +pub fn chamfer_op( + edges: Vec, + base_face: i32, + base_distance: f64, + other_distance: f64, +) -> SolidHistoryOperation { + SolidHistoryOperation::Chamfer(SolidHistoryChamfer { + base: base(glam::DMat4::IDENTITY.to_cols_array()), + operation_major: 1, + method: 0, + base_distance, + other_distance, + edges, + base_face, + ..SolidHistoryChamfer::default() + }) +} diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 24969c146..fedae9365 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -365,13 +365,18 @@ pub enum Bool { /// missing, and passing that on unchanged is the point: a half-done boolean /// looks finished. pub fn boolean(op: Bool, a: &Body, b: &Body) -> Option { + boolean_result(op, a, b).ok() +} + +/// Combine two solids while retaining the kernel's exact refusal reason. +pub fn boolean_result(op: Bool, a: &Body, b: &Body) -> Result { let how = match op { Bool::Union => brep::Operation::Union, Bool::Subtract => brep::Operation::Difference, Bool::Intersect => brep::Operation::Intersection, }; let tolerance = brep::operation_tolerance(&[a, b]); - brep::combine(a.clone(), b.clone(), how, tolerance).ok() + brep::combine(a.clone(), b.clone(), how, tolerance) } // ── Tessellation ──────────────────────────────────────────────────────────── @@ -470,7 +475,11 @@ pub fn display_from_solid( ) }) .collect(); - Some((mesh_from_tessellation(tessellation, color)?, wires, center)) + let mut mesh = mesh_from_tessellation(tessellation, color)?; + if let Some(properties) = cadkernel::brep::analytic_mass_properties(body) { + mesh.apply_mass_properties(properties); + } + Some((mesh, wires, center)) } /// The middle of a body, for a caller needing a point to turn or scale about. diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 0802ec5b7..d8ed4fa75 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -807,11 +807,39 @@ impl Scene { true } + pub fn append_solid_history( + &mut self, + handle: Handle, + operation: acadrust::objects::SolidHistoryOperation, + ) -> bool { + let previous = self + .document + .solid_history_graph(handle) + .map(|graph| graph.nodes) + .unwrap_or_default(); + self.record_solid_history_before(handle); + let Some(graph) = self.document.append_solid_history(handle, operation) else { + return false; + }; + for node in graph.nodes { + if !previous.contains(&node) { + self.record_undo_object_before(node, None); + } + } + self.sync_solid_reference_point(handle); + true + } + pub(crate) fn sync_solid_reference_point(&mut self, handle: Handle) { let reference = self .document - .solid_history_operation(handle) - .and_then(crate::scene::model::solid_history::reference_point); + .solid_history_operations(handle) + .and_then(|operations| { + operations + .iter() + .rev() + .find_map(crate::scene::model::solid_history::reference_point) + }); let Some(reference) = reference else { return; }; @@ -860,8 +888,31 @@ impl Scene { (EntityType::Surface(_), SolidHistoryOperation::Extrusion(value)) => { cadkernel::acis::rebuild_extrusion_with_mode(value, true).ok() } - (EntityType::Surface(_), SolidHistoryOperation::Loft(_)) - | (EntityType::Solid3D(_), _) => cadkernel::acis::rebuild_body(operation).ok(), + (EntityType::Surface(_), SolidHistoryOperation::Loft(_)) => { + cadkernel::acis::rebuild_body(operation).ok() + } + (EntityType::Solid3D(_), _) => { + let mut operations = self.document.solid_history_operations(handle)?; + let replacement_id = operation.base().map(|base| { + if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + } + })?; + let target = operations.iter_mut().find(|candidate| { + candidate.base().is_some_and(|base| { + let node_id = if base.eval.node_id > 0 { + base.eval.node_id + } else { + base.step_id + }; + node_id == replacement_id + }) + })?; + *target = operation.clone(); + cadkernel::acis::rebuild_history(&operations).ok() + } _ => None, } } @@ -914,7 +965,7 @@ impl Scene { self.record_solid_history_before(handle); if self .document - .update_solid_history(handle, operation) + .update_solid_history_step(handle, operation) .is_none() { return false; @@ -1051,16 +1102,71 @@ impl Scene { value: &str, ) -> bool { self.record_solid_history_before(handle); + let mut created = false; + if self.document.solid_history_graph(handle).is_none() { + let create = match field { + crate::scene::model::solid_history::PROP_HISTORY => { + if value.eq_ignore_ascii_case("Record") { + true + } else if value.eq_ignore_ascii_case("None") { + return false; + } else { + return false; + } + } + crate::scene::model::solid_history::PROP_SHOW_HISTORY + if self.document.header.show_solid_history.clamp(0, 2) == 1 => + { + if value.eq_ignore_ascii_case("Yes") { + true + } else if value.eq_ignore_ascii_case("No") { + return false; + } else { + return false; + } + } + _ => return false, + }; + if create { + if !matches!(self.document.get_entity(handle), Some(EntityType::Solid3D(_))) { + return false; + } + self.restore_solid_models(&[handle]); + let Some(body) = self.solid_models.get(&handle).cloned() else { + return false; + }; + if self.is_recording_undo() { + let before = self.document.get_entity_arc(handle); + self.record_undo_before(handle, before); + } + if !self.create_solid_history( + handle, + crate::scene::model::solid_history::brep_op(&body), + ) { + return false; + } + // A solid without a graph was displayed as History=None. + // Preserve that object state before applying the requested + // choice, independent of the drawing-wide SOLIDHIST setting. + crate::scene::model::solid_history::apply_history_choice( + &mut self.document, + handle, + crate::scene::model::solid_history::PROP_HISTORY, + "None", + ); + created = true; + } + } let applied = crate::scene::model::solid_history::apply_history_choice( &mut self.document, handle, field, value, ); - if applied { + if created || applied { self.bump_entities(&[(handle, ChangeKind::Modified)]); } - applied + created || applied } fn apply_solid_history_grip( @@ -1072,6 +1178,79 @@ impl Scene { let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { return false; }; + if grip_id == crate::scene::model::solid_history::GRIP_FILLET_RADIUS { + let acadrust::objects::SolidHistoryOperation::Fillet(value) = &mut operation else { + return false; + }; + let Some(radius) = value.radii.first().copied() else { + return false; + }; + self.restore_solid_models(&[handle]); + let Some(definition) = self + .solid_models + .get(&handle) + .and_then(|body| { + crate::scene::model::solid_history::fillet_radius_grip(body, radius) + }) + else { + return false; + }; + let Some(axis) = definition.axis else { + return false; + }; + let change = match apply { + GripApply::Absolute(world) => (world - definition.world).dot(axis), + GripApply::Translate(delta) => delta.dot(axis), + }; + let radius = radius + change; + if !radius.is_finite() || radius <= 1.0e-6 { + return false; + } + value.radii.clear(); + value.radii.push(radius); + return self.preview_solid_history(handle, operation); + } + if matches!( + grip_id, + crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + | crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE2 + ) { + let definitions = { + let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &operation else { + return false; + }; + crate::scene::model::solid_history::chamfer_distance_grips( + &self.document, + handle, + value, + ) + }; + let Some(definition) = definitions.into_iter().find(|grip| grip.id == grip_id) else { + return false; + }; + let Some(axis) = definition.axis else { + return false; + }; + let change = match apply { + GripApply::Absolute(world) => (world - definition.world).dot(axis), + GripApply::Translate(delta) => delta.dot(axis), + }; + let acadrust::objects::SolidHistoryOperation::Chamfer(value) = &mut operation else { + return false; + }; + let distance = if grip_id + == crate::scene::model::solid_history::GRIP_CHAMFER_DISTANCE1 + { + &mut value.base_distance + } else { + &mut value.other_distance + }; + *distance += change; + if !distance.is_finite() || *distance <= 1.0e-6 { + return false; + } + return self.preview_solid_history(handle, operation); + } if !crate::scene::model::solid_history::apply_primitive_grip( &mut operation, grip_id, @@ -1088,7 +1267,12 @@ impl Scene { field: &str, value: &str, ) -> bool { - let Some(mut operation) = self.document.solid_history_operation(handle).cloned() else { + let Some(mut operation) = + crate::scene::model::solid_history::primitive_property_operation( + &self.document, + handle, + ) + else { return false; }; if !crate::scene::model::solid_history::apply_primitive_property( @@ -1185,9 +1369,7 @@ impl Scene { generator.source = source; } } - set.metrics.centroid[0] += delta[0]; - set.metrics.centroid[1] += delta[1]; - set.metrics.centroid[2] += delta[2]; + set.metrics.translate(delta); set.recompute_aabb(); } @@ -1337,5 +1519,14 @@ impl Scene { self.images.insert(handle, model); } } + // A grip edit can move geometry used as the boundary of an associative hatch. + // The source entity has already been mutated above, so rebuild any dependent + // hatch from the live document geometry immediately. + let source_change = [(handle, ChangeKind::Modified)]; + let hatch_changes = self.refresh_associative_hatches(&source_change); + + if !hatch_changes.is_empty() { + self.bump_entities(&hatch_changes); + } } } diff --git a/src/scene/pick/hit_test.rs b/src/scene/pick/hit_test.rs index 209a88ba1..f9520c760 100644 --- a/src/scene/pick/hit_test.rs +++ b/src/scene/pick/hit_test.rs @@ -1615,10 +1615,10 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( ); } - // Q: lazy projection — accumulate screen points without allocating per-wire Vec. - wires - .iter() - .filter_map(|wire| { + if crossing { + let mut out = Vec::new(); + let mut seen = HashSet::default(); + for wire in wires.iter() { // Fallback: when wire has no line geometry (e.g. greek text emits // only fill_tris) treat the AABB rectangle as the hit-test shape // so low-LOD text stays selectable. See #19. @@ -1639,7 +1639,7 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( ]; &aabb_pts } else { - return None; + continue; }; // Low residual parallel to `pts` (empty for the AABB fallback, @@ -1650,6 +1650,85 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( &[] }; let mut hit = false; + let mut prev: Option = None; + + for (i, &[px, py, pz]) in pts.iter().enumerate() { + if px.is_nan() { + prev = None; + continue; + } + let world = if !wire.points.is_empty() { + wire_point_world(wire, i, view_rot, eye) + } else { + wp64([px, py, pz], low, i) + }; + let sp = world_to_screen(world, view_rot, eye, bounds); + if inside(sp) { + hit = true; + } + if let Some(p0) = prev { + if !hit { + hit = segments_intersect(p0, sp, box_tl, box_tr) + || segments_intersect(p0, sp, box_tr, box_br) + || segments_intersect(p0, sp, box_br, box_bl) + || segments_intersect(p0, sp, box_bl, box_tl); + } + } + prev = Some(sp); + } + + let mut glyph_crosses = false; + for start in (0..wire.text_verts.len()).step_by(6) { + let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { + continue; + }; + if [0usize, 3].into_iter().any(|offset| { + triangle_crosses_box( + [screen[offset], screen[offset + 1], screen[offset + 2]], + box_corners, + ) + }) { + glyph_crosses = true; + break; + } + } + + if (hit || glyph_crosses) && seen.insert(wire.name.as_str()) { + out.push(wire.name.as_str()); + } + } + out + } else { + let mut qualified = Vec::new(); + let mut disqualified = HashSet::default(); + let mut seen = HashSet::default(); + + for wire in wires.iter() { + let aabb_pts: Vec<[f32; 3]>; + let empty_pts: [[f32; 3]; 0] = []; + let pts: &[[f32; 3]] = if !wire.points.is_empty() { + &wire.points + } else if !wire.text_verts.is_empty() { + &empty_pts + } else if wire.aabb != WireModel::UNBOUNDED_AABB { + let [ax, ay, bx, by] = wire.aabb; + aabb_pts = vec![ + [ax, ay, 0.0], + [bx, ay, 0.0], + [bx, by, 0.0], + [ax, by, 0.0], + [ax, ay, 0.0], + ]; + &aabb_pts + } else { + continue; + }; + + let low: &[[f32; 3]] = if !wire.points.is_empty() { + &wire.points_low + } else { + &[] + }; let mut all_inside = true; let mut prev: Option = None; @@ -1664,61 +1743,42 @@ pub fn box_hit<'a, W: WireSource + ?Sized>( wp64([px, py, pz], low, i) }; let sp = world_to_screen(world, view_rot, eye, bounds); - if crossing { - if inside(sp) { - hit = true; - } - if let Some(p0) = prev { - if !hit { - hit = segments_intersect(p0, sp, box_tl, box_tr) - || segments_intersect(p0, sp, box_tr, box_br) - || segments_intersect(p0, sp, box_br, box_bl) - || segments_intersect(p0, sp, box_bl, box_tl); - } - } - } else { - if !inside(sp) { - all_inside = false; - } + if !inside(sp) { + all_inside = false; } prev = Some(sp); } let glyphs_present = !wire.text_verts.is_empty(); - let mut glyph_crosses = false; let mut glyphs_inside = true; for start in (0..wire.text_verts.len()).step_by(6) { let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { continue; }; - if crossing { - if [0usize, 3].into_iter().any(|offset| { - triangle_crosses_box( - [screen[offset], screen[offset + 1], screen[offset + 2]], - box_corners, - ) - }) { - glyph_crosses = true; - break; - } - } else if !screen.iter().copied().all(inside) { + if !screen.iter().copied().all(inside) { glyphs_inside = false; break; } } - let result = if crossing { - hit || glyph_crosses - } else { - all_inside && glyphs_inside && (prev.is_some() || glyphs_present) - }; - if result { - Some(wire.name.as_str()) - } else { - None + let has_geom = prev.is_some() || glyphs_present; + if !has_geom { + continue; } - }) - .collect() + + let name = wire.name.as_str(); + if all_inside && glyphs_inside { + if seen.insert(name) { + qualified.push(name); + } + } else { + disqualified.insert(name); + } + } + + qualified.retain(|name| !disqualified.contains(name)); + qualified + } } // ── Polygon / lasso selection ───────────────────────────────────────────── @@ -1743,10 +1803,10 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( return indexed_polygon_crossing_hits(wires, poly, view_rot, eye, bounds); } - // Q: lazy projection — no Vec allocation per wire. - wires - .iter() - .filter_map(|wire| { + if crossing { + let mut out = Vec::new(); + let mut seen = HashSet::default(); + for wire in wires.iter() { // Same AABB fallback as `box_hit`: when a wire has no line // geometry (e.g. greek-LOD text emits only fill_tris) treat the // AABB rectangle as the hit-test shape so low-LOD text stays @@ -1768,7 +1828,7 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( ]; &aabb_pts } else { - return None; + continue; }; let low: &[[f32; 3]] = if !wire.points.is_empty() { @@ -1777,6 +1837,88 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( &[] }; let mut hit = false; + let mut prev: Option = None; + + for (i, &[px, py, pz]) in pts.iter().enumerate() { + if px.is_nan() { + prev = None; + continue; + } + let world = if !wire.points.is_empty() { + wire_point_world(wire, i, view_rot, eye) + } else { + wp64([px, py, pz], low, i) + }; + let sp = world_to_screen(world, view_rot, eye, bounds); + if sp.x < 0.0 || sp.x > bounds.width || sp.y < 0.0 || sp.y > bounds.height { + prev = None; + continue; + } + if point_in_polygon(sp, poly) { + hit = true; + } + if !hit { + if let Some(p0) = prev { + if segment_crosses_polygon(p0, sp, poly) { + hit = true; + } + } + } + prev = Some(sp); + } + + let mut glyph_crosses = false; + for start in (0..wire.text_verts.len()).step_by(6) { + let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { + continue; + }; + if [0usize, 3].into_iter().any(|offset| { + triangle_crosses_polygon( + [screen[offset], screen[offset + 1], screen[offset + 2]], + poly, + ) + }) { + glyph_crosses = true; + break; + } + } + + if (hit || glyph_crosses) && seen.insert(wire.name.as_str()) { + out.push(wire.name.as_str()); + } + } + out + } else { + let mut qualified = Vec::new(); + let mut disqualified = HashSet::default(); + let mut seen = HashSet::default(); + + for wire in wires.iter() { + let aabb_pts: Vec<[f32; 3]>; + let empty_pts: [[f32; 3]; 0] = []; + let pts: &[[f32; 3]] = if !wire.points.is_empty() { + &wire.points + } else if !wire.text_verts.is_empty() { + &empty_pts + } else if wire.aabb != WireModel::UNBOUNDED_AABB { + let [ax, ay, bx, by] = wire.aabb; + aabb_pts = vec![ + [ax, ay, 0.0], + [bx, ay, 0.0], + [bx, by, 0.0], + [ax, by, 0.0], + [ax, ay, 0.0], + ]; + &aabb_pts + } else { + continue; + }; + + let low: &[[f32; 3]] = if !wire.points.is_empty() { + &wire.points_low + } else { + &[] + }; let mut all_inside = true; let mut prev: Option = None; @@ -1798,43 +1940,19 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( prev = None; continue; } - if crossing { - if point_in_polygon(sp, poly) { - hit = true; - } - if !hit { - if let Some(p0) = prev { - if segment_crosses_polygon(p0, sp, poly) { - hit = true; - } - } - } - } else { - if !point_in_polygon(sp, poly) { - all_inside = false; - } + if !point_in_polygon(sp, poly) { + all_inside = false; } prev = Some(sp); } let glyphs_present = !wire.text_verts.is_empty(); - let mut glyph_crosses = false; let mut glyphs_inside = true; for start in (0..wire.text_verts.len()).step_by(6) { let Some(screen) = projected_text_quad(wire, start, view_rot, eye, bounds) else { continue; }; - if crossing { - if [0usize, 3].into_iter().any(|offset| { - triangle_crosses_polygon( - [screen[offset], screen[offset + 1], screen[offset + 2]], - poly, - ) - }) { - glyph_crosses = true; - break; - } - } else if !screen.iter().copied().all(|point| { + if !screen.iter().copied().all(|point| { point.x >= 0.0 && point.x <= bounds.width && point.y >= 0.0 @@ -1846,18 +1964,24 @@ pub fn poly_hit<'a, W: WireSource + ?Sized>( } } - let result = if crossing { - hit || glyph_crosses - } else { - all_inside && glyphs_inside && (prev.is_some() || glyphs_present) - }; - if result { - Some(wire.name.as_str()) - } else { - None + let has_geom = prev.is_some() || glyphs_present; + if !has_geom { + continue; } - }) - .collect() + + let name = wire.name.as_str(); + if all_inside && glyphs_inside { + if seen.insert(name) { + qualified.push(name); + } + } else { + disqualified.insert(name); + } + } + + qualified.retain(|name| !disqualified.contains(name)); + qualified + } } // ── Helpers ─────────────────────────────────────────────────────────────── @@ -2511,4 +2635,80 @@ mod aabb_reject_tests { vec!["479"], ); } + + #[test] + fn window_selection_multi_wire_entity_behavior() { + let bounds = Rectangle { + x: 0.0, + y: 0.0, + width: 200.0, + height: 200.0, + }; + // In identity ortho view: + // world (0.0, 0.0) -> screen (100.0, 100.0) + // world (0.1, 0.1) -> screen (110.0, 90.0) + // world (0.8, 0.8) -> screen (180.0, 20.0) + let wire_arc = wire( + "4F", + vec![[0.0, 0.0, 0.0], [0.1, 0.1, 0.0]], + [0.0, 0.0, 0.1, 0.1], + ); + let wire_lines = wire( + "4F", + vec![[0.1, 0.1, 0.0], [0.8, 0.8, 0.0]], + [0.1, 0.1, 0.8, 0.8], + ); + let wires = [wire_arc, wire_lines]; + + // Box enclosing ONLY wire_arc (screen 95..115, 85..105): + let box_a = Point::new(95.0, 85.0); + let box_b = Point::new(115.0, 105.0); + let poly_small = [ + Point::new(95.0, 85.0), + Point::new(115.0, 85.0), + Point::new(115.0, 105.0), + Point::new(95.0, 105.0), + ]; + + // Crossing mode selects "4F" because wire_arc is inside: + assert_eq!( + box_hit(box_a, box_b, true, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + vec!["4F"] + ); + assert_eq!( + poly_hit(&poly_small, true, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + vec!["4F"] + ); + + // Window mode MUST NOT select "4F" because wire_lines has a vertex at (0.8, 0.8) -> (180.0, 20.0) outside: + assert_eq!( + box_hit(box_a, box_b, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + Vec::<&str>::new() + ); + assert_eq!( + poly_hit(&poly_small, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + Vec::<&str>::new() + ); + + // Big box enclosing BOTH wire_arc and wire_lines (screen 50..190, 10..110): + let big_a = Point::new(50.0, 10.0); + let big_b = Point::new(190.0, 110.0); + let poly_big = [ + Point::new(50.0, 10.0), + Point::new(190.0, 10.0), + Point::new(190.0, 110.0), + Point::new(50.0, 110.0), + ]; + + // Window mode now selects "4F" once (deduplicated): + assert_eq!( + box_hit(big_a, big_b, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + vec!["4F"] + ); + assert_eq!( + poly_hit(&poly_big, false, &wires, Mat4::IDENTITY, glam::DVec3::ZERO, bounds), + vec!["4F"] + ); + } } + diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 26f0b2982..0378dc7a7 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -197,6 +197,9 @@ pub fn clip_wires(wires: &mut Vec, poly: &[[f64; 2]]) { } else { w.pattern_stations.clear(); } + if !w.tangent_geoms.is_empty() { + w.tangent_geoms.clear(); + } } if !w.fill_tris.is_empty() { let local: Vec<[f32; 3]> = (0..w.fill_tris.len()) diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs new file mode 100644 index 000000000..d62902a64 --- /dev/null +++ b/src/scene/pipeline/circle_gpu.rs @@ -0,0 +1,413 @@ +//! GPU-instanced analytical circle buffer & pipeline layout. +//! +//! Renders 2D/planar circles as analytical screen/plane-aligned quads with +//! screen-space derivative anti-aliasing. Each circle is a single instance +//! (6 vertices for a 2-triangle quad), eliminating CPU re-tessellation on zoom +//! and providing infinite visual smoothness. + +use iced::wgpu; + +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct CircleInstance { + pub center_high: [f32; 3], + pub _pad0: f32, + pub center_low: [f32; 3], + pub radius: f32, + pub axis_x: [f32; 3], + pub start_angle: f32, + pub axis_y: [f32; 3], + pub end_angle: f32, + pub color: [f32; 4], + /// `[half_width, pattern_length, draw_depth, padding]` + pub params: [f32; 4], + pub pat0: [f32; 4], + pub pat1: [f32; 4], +} + +impl CircleInstance { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, center_high) as u64, + shader_location: 0, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, center_low) as u64, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, axis_x) as u64, + shader_location: 2, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, axis_y) as u64, + shader_location: 3, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, color) as u64, + shader_location: 4, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, params) as u64, + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, pat0) as u64, + shader_location: 6, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(CircleInstance, pat1) as u64, + shader_location: 7, + format: wgpu::VertexFormat::Float32x4, + }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Instance, + attributes: ATTRS, + } + } +} + +pub struct CircleGpu { + pub instance_buffer: wgpu::Buffer, + pub instance_count: u32, +} + +impl CircleGpu { + pub fn from_instances( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + instances: &[CircleInstance], + ) -> Self { + let instance_buffer = super::gpu_upload::upload_buffer( + device, + queue, + label, + instances, + wgpu::BufferUsages::VERTEX, + ); + Self { + instance_buffer, + instance_count: instances.len() as u32, + } + } +} + +/// Build the base and xray circle pipelines. +pub fn create_pipelines( + device: &wgpu::Device, + frame_bgl: &wgpu::BindGroupLayout, + color_format: wgpu::TextureFormat, + sample_count: u32, + content_stencil: &wgpu::StencilState, +) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("circle.wgsl"), + source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/circle.wgsl").into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("circle.pipeline.layout"), + bind_group_layouts: &[Some(frame_bgl)], + immediate_size: 0, + }); + let circle_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("circle.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[CircleInstance::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + let circle_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("circle_xray.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[CircleInstance::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + (circle_pipeline, circle_xray_pipeline) +} + +/// Helper to extract an analytical circle or circular arc instance from a single `TangentGeom`. +pub fn extract_circle_instance_from_geom( + geom: &crate::scene::model::wire_model::TangentGeom, + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option { + let (center, axis_x, axis_y, radius, start_angle, end_angle) = match *geom { + crate::scene::model::wire_model::TangentGeom::Circle { center, radius } => ( + [center[0] as f64, center[1] as f64, center[2] as f64], + [1.0, 0.0, 0.0], + [0.0, 1.0, 0.0], + radius as f64, + 0.0f32, + std::f32::consts::TAU, + ), + crate::scene::model::wire_model::TangentGeom::PlanarCircle { + center, + axis_x, + axis_y, + radius, + } => (center, axis_x, axis_y, radius, 0.0f32, std::f32::consts::TAU), + crate::scene::model::wire_model::TangentGeom::Arc { + center, + axis_x, + axis_y, + radius, + start_angle, + end_angle, + } => ( + center, + axis_x, + axis_y, + radius, + start_angle as f32, + end_angle as f32, + ), + _ => return None, + }; + if radius <= 0.0 || !radius.is_finite() || !start_angle.is_finite() || !end_angle.is_finite() || radius > 1e6 { + return None; + } + + let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]); + let hw = wire.line_weight_px * 0.5; + let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]]; + let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]]; + + Some(CircleInstance { + center_high: ch, + _pad0: 0.0, + center_low: cl, + radius: radius as f32, + axis_x: [axis_x[0] as f32, axis_x[1] as f32, axis_x[2] as f32], + start_angle, + axis_y: [axis_y[0] as f32, axis_y[1] as f32, axis_y[2] as f32], + end_angle, + color: wire.color, + params: [hw, wire.pattern_length, draw_depth, 0.0], + pat0, + pat1, + }) +} + +/// Helper to extract all analytical circle/arc instances from a `WireModel`. +/// +/// Returns `Some(instances)` if the wire is non-empty, contains solely analytical +/// circle/arc tangent geometries, and has no mesh/pick fills, text, or block instance. +pub fn extract_circle_instances( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option> { + if wire.tangent_geoms.is_empty() + || !wire.fill_tris.is_empty() + || !wire.pick_tris.is_empty() + || !wire.text_verts.is_empty() + || wire.render_instance.is_some() + { + return None; + } + + let mut instances = Vec::with_capacity(wire.tangent_geoms.len()); + for geom in &wire.tangent_geoms { + if let Some(inst) = extract_circle_instance_from_geom(geom, wire, draw_depth) { + instances.push(inst); + } else { + return None; + } + } + Some(instances) +} + +/// Helper to extract a single analytical circle or circular arc instance from a `WireModel`. +pub fn extract_circle_instance( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option { + if wire.tangent_geoms.len() == 1 { + extract_circle_instances(wire, draw_depth).and_then(|mut v| v.pop()) + } else { + None + } +} + +fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { + let hx = x as f32; + let lx = (x - hx as f64) as f32; + let hy = y as f32; + let ly = (y - hy as f64) as f32; + let hz = z as f32; + let lz = (z - hz as f64) as f32; + ([hx, hy, hz], [lx, ly, lz]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn circle_instance_size_matches_stride() { + assert_eq!(std::mem::size_of::(), 128); + } + + #[test] + fn circle_shader_validates_with_naga() { + let source = include_str!("../../shaders/circle.wgsl"); + let module = naga::front::wgsl::parse_str(source).expect("circle.wgsl parses cleanly"); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .expect("circle.wgsl validates cleanly with naga"); + } + + #[test] + fn extract_circle_instance_planar_success() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle { + center: [100.0, 200.0, 300.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 50.0, + }); + wire.line_weight_px = 2.0; + wire.color = [1.0, 0.0, 0.0, 1.0]; + + let inst = extract_circle_instance(&wire, 1.5).expect("should extract planar circle"); + assert_eq!(inst.radius, 50.0); + assert_eq!(inst.start_angle, 0.0); + assert_eq!(inst.end_angle, std::f32::consts::TAU); + assert_eq!(inst.center_high, [100.0, 200.0, 300.0]); + assert_eq!(inst.color, [1.0, 0.0, 0.0, 1.0]); + assert_eq!(inst.params[0], 1.0); // half lineweight + assert_eq!(inst.params[2], 1.5); // draw depth + } + + #[test] + fn extract_circle_instance_arc_success() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::Arc { + center: [10.0, 20.0, 30.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 25.0, + start_angle: 0.5, + end_angle: 2.5, + }); + wire.line_weight_px = 3.0; + wire.color = [0.0, 1.0, 0.0, 1.0]; + + let inst = extract_circle_instance(&wire, 0.5).expect("should extract circular arc"); + assert_eq!(inst.radius, 25.0); + assert_eq!(inst.start_angle, 0.5); + assert_eq!(inst.end_angle, 2.5); + assert_eq!(inst.center_high, [10.0, 20.0, 30.0]); + assert_eq!(inst.color, [0.0, 1.0, 0.0, 1.0]); + assert_eq!(inst.params[0], 1.5); + } + + #[test] + fn extract_circle_instance_rejects_non_planar_or_complex() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle { + center: [0.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 10.0, + }); + // 1. With fill_tris (e.g. 3D solid or hatch) + wire.fill_tris = vec![[0.0; 3]]; + assert!(extract_circle_instance(&wire, 0.0).is_none()); + + // 2. Clear fill_tris, add render_instance (block instance) + wire.fill_tris.clear(); + wire.render_instance = Some(crate::scene::model::instance_model::RenderInstance { + source_id: 1, + translation: [0.0; 3], + }); + assert!(extract_circle_instance(&wire, 0.0).is_none()); + + // 3. Invalid radius + wire.render_instance = None; + wire.tangent_geoms[0] = crate::scene::model::wire_model::TangentGeom::PlanarCircle { + center: [0.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: -5.0, + }; + assert!(extract_circle_instance(&wire, 0.0).is_none()); + } +} diff --git a/src/scene/pipeline/ellipse_gpu.rs b/src/scene/pipeline/ellipse_gpu.rs new file mode 100644 index 000000000..50bc2e59d --- /dev/null +++ b/src/scene/pipeline/ellipse_gpu.rs @@ -0,0 +1,450 @@ +//! GPU-instanced analytical ellipse buffer & pipeline layout. +//! +//! Renders 2D/planar ellipses and elliptical arcs as analytical screen/plane-aligned quads +//! with sub-pixel screen-space derivative anti-aliasing. Each ellipse is a single instance +//! (6 vertices for a 2-triangle quad), eliminating CPU re-tessellation on zoom and providing +//! mathematically exact curvature at any magnification. + +use iced::wgpu; + +#[repr(C)] +#[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] +pub struct EllipseInstance { + pub center_high: [f32; 3], + pub _pad0: f32, + pub center_low: [f32; 3], + pub minor_axis_ratio: f32, + pub major_axis: [f32; 3], + pub start_param: f32, + pub normal: [f32; 3], + pub end_param: f32, + pub color: [f32; 4], + /// `[half_width, pattern_length, draw_depth, padding]` + pub params: [f32; 4], + pub pat0: [f32; 4], + pub pat1: [f32; 4], +} + +impl EllipseInstance { + pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> { + const ATTRS: &[wgpu::VertexAttribute] = &[ + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, center_high) as u64, + shader_location: 0, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, center_low) as u64, + shader_location: 1, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, major_axis) as u64, + shader_location: 2, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, normal) as u64, + shader_location: 3, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, color) as u64, + shader_location: 4, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, params) as u64, + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, pat0) as u64, + shader_location: 6, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: std::mem::offset_of!(EllipseInstance, pat1) as u64, + shader_location: 7, + format: wgpu::VertexFormat::Float32x4, + }, + ]; + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as u64, + step_mode: wgpu::VertexStepMode::Instance, + attributes: ATTRS, + } + } +} + +pub struct EllipseGpu { + pub instance_buffer: wgpu::Buffer, + pub instance_count: u32, +} + +impl EllipseGpu { + pub fn from_instances( + device: &wgpu::Device, + queue: &wgpu::Queue, + label: &str, + instances: &[EllipseInstance], + ) -> Self { + let instance_buffer = super::gpu_upload::upload_buffer( + device, + queue, + label, + instances, + wgpu::BufferUsages::VERTEX, + ); + Self { + instance_buffer, + instance_count: instances.len() as u32, + } + } +} + +/// Build the base and xray ellipse pipelines. +pub fn create_pipelines( + device: &wgpu::Device, + frame_bgl: &wgpu::BindGroupLayout, + color_format: wgpu::TextureFormat, + sample_count: u32, + content_stencil: &wgpu::StencilState, +) -> (wgpu::RenderPipeline, wgpu::RenderPipeline) { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("ellipse.wgsl"), + source: wgpu::ShaderSource::Wgsl(include_str!("../../shaders/ellipse.wgsl").into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ellipse.pipeline.layout"), + bind_group_layouts: &[Some(frame_bgl)], + immediate_size: 0, + }); + let ellipse_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ellipse.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[EllipseInstance::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + let ellipse_xray_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("ellipse_xray.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[EllipseInstance::layout()], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24PlusStencil8, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: content_stencil.clone(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + (ellipse_pipeline, ellipse_xray_pipeline) +} + +/// Helper to extract an analytical ellipse instance from a single `TangentGeom`. +pub fn extract_ellipse_instance_from_geom( + geom: &crate::scene::model::wire_model::TangentGeom, + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option { + let crate::scene::model::wire_model::TangentGeom::PlanarEllipse { + center, + major_axis, + normal, + minor_axis_ratio, + start_param, + end_param, + } = *geom else { + return None; + }; + + let major_len_sq = major_axis[0] * major_axis[0] + major_axis[1] * major_axis[1] + major_axis[2] * major_axis[2]; + let norm_len_sq = normal[0] * normal[0] + normal[1] * normal[1] + normal[2] * normal[2]; + if major_len_sq <= 1e-12 || !major_len_sq.is_finite() || major_len_sq > 1e12 + || norm_len_sq <= 1e-12 || !norm_len_sq.is_finite() + || minor_axis_ratio <= 1e-6 || !minor_axis_ratio.is_finite() + || !start_param.is_finite() || !end_param.is_finite() + { + return None; + } + + let (ch, cl) = split_ds_xyz(center[0], center[1], center[2]); + let hw = wire.line_weight_px * 0.5; + let pat0 = [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]]; + let pat1 = [wire.pattern[4], wire.pattern[5], wire.pattern[6], wire.pattern[7]]; + + Some(EllipseInstance { + center_high: ch, + _pad0: 0.0, + center_low: cl, + minor_axis_ratio: minor_axis_ratio.min(1.0) as f32, + major_axis: [major_axis[0] as f32, major_axis[1] as f32, major_axis[2] as f32], + start_param: start_param as f32, + normal: [normal[0] as f32, normal[1] as f32, normal[2] as f32], + end_param: end_param as f32, + color: wire.color, + params: [hw, wire.pattern_length, draw_depth, 0.0], + pat0, + pat1, + }) +} + +/// Helper to extract all analytical ellipse instances from a `WireModel`. +pub fn extract_ellipse_instances( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option> { + if wire.tangent_geoms.is_empty() + || !wire.fill_tris.is_empty() + || !wire.pick_tris.is_empty() + || !wire.text_verts.is_empty() + || wire.render_instance.is_some() + { + return None; + } + + let mut instances = Vec::with_capacity(wire.tangent_geoms.len()); + for geom in &wire.tangent_geoms { + if let Some(inst) = extract_ellipse_instance_from_geom(geom, wire, draw_depth) { + instances.push(inst); + } else { + return None; + } + } + Some(instances) +} + +/// Helper to extract a single analytical ellipse instance from a `WireModel`. +pub fn extract_ellipse_instance( + wire: &crate::scene::WireModel, + draw_depth: f32, +) -> Option { + if wire.tangent_geoms.len() == 1 { + extract_ellipse_instances(wire, draw_depth).and_then(|mut v| v.pop()) + } else { + None + } +} + +fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) { + let hx = x as f32; + let lx = (x - hx as f64) as f32; + let hy = y as f32; + let ly = (y - hy as f64) as f32; + let hz = z as f32; + let lz = (z - hz as f64) as f32; + ([hx, hy, hz], [lx, ly, lz]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ellipse_instance_size_matches_stride() { + assert_eq!(std::mem::size_of::(), 128); + } + + #[test] + fn ellipse_shader_validates_with_naga() { + let source = include_str!("../../shaders/ellipse.wgsl"); + let module = naga::front::wgsl::parse_str(source).expect("ellipse.wgsl parses cleanly"); + let mut validator = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ); + validator + .validate(&module) + .expect("ellipse.wgsl validates cleanly with naga"); + } + + #[test] + fn extract_ellipse_instance_success() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse { + center: [100.0, 200.0, 300.0], + major_axis: [50.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.5, + start_param: 0.0, + end_param: std::f64::consts::TAU, + }); + wire.line_weight_px = 2.0; + wire.color = [1.0, 0.5, 0.0, 1.0]; + + let inst = extract_ellipse_instance(&wire, 1.0).expect("should extract ellipse"); + assert_eq!(inst.center_high, [100.0, 200.0, 300.0]); + assert_eq!(inst.major_axis, [50.0, 0.0, 0.0]); + assert_eq!(inst.minor_axis_ratio, 0.5); + assert_eq!(inst.start_param, 0.0); + assert_eq!(inst.end_param, std::f32::consts::TAU); + assert_eq!(inst.color, [1.0, 0.5, 0.0, 1.0]); + assert_eq!(inst.params[0], 1.0); + assert_eq!(inst.params[2], 1.0); + } + + #[test] + fn extract_elliptical_arc_instance_success() { + let mut wire = crate::scene::WireModel::default(); + wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse { + center: [0.0, 0.0, 0.0], + major_axis: [10.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.8, + start_param: 0.5, + end_param: 2.5, + }); + wire.line_weight_px = 1.0; + wire.color = [0.0, 1.0, 0.0, 1.0]; + + let inst = extract_ellipse_instance(&wire, 0.5).expect("should extract elliptical arc"); + assert_eq!(inst.minor_axis_ratio, 0.8); + assert_eq!(inst.start_param, 0.5); + assert_eq!(inst.end_param, 2.5); + assert_eq!(inst.params[0], 0.5); // half-width + assert_eq!(inst.params[2], 0.5); // draw-depth + } + + fn rust_closest_point_ellipse(p_in: [f32; 2], ab_in: [f32; 2]) -> [f32; 2] { + let px = p_in[0].abs(); + let py = p_in[1].abs(); + let a = ab_in[0]; + let b = ab_in[1]; + if (a - b).abs() < 1e-6 { + let len = (px * px + py * py).sqrt(); + let q = if len > 1e-6 { + [px / len * a, py / len * a] + } else { + [a, 0.0] + }; + return [ + if p_in[0] >= 0.0 { q[0] } else { -q[0] }, + if p_in[1] >= 0.0 { q[1] } else { -q[1] }, + ]; + } + + // Initial estimate from eccentric anomaly of radial direction + let mut t = (py * a).atan2(px * b); + let k = a * a - b * b; + + // 3 iterations of Newton-Raphson + for _ in 0..3 { + let (st, ct) = t.sin_cos(); + let f = k * st * ct - px * a * st + py * b * ct; + let f_prime = k * (ct * ct - st * st) - px * a * ct - py * b * st; + if f_prime.abs() > 1e-7 { + t -= f / f_prime; + } + } + t = t.clamp(0.0, std::f32::consts::FRAC_PI_2); + let q = [a * t.cos(), b * t.sin()]; + [ + if p_in[0] >= 0.0 { q[0] } else { -q[0] }, + if p_in[1] >= 0.0 { q[1] } else { -q[1] }, + ] + } + + #[test] + fn closest_point_ellipse_accuracy() { + let a = 100.0f32; + let b = 40.0f32; + // Test points directly on the ellipse: closest point should be itself! + for i in 0..36 { + let angle = (i as f32) * std::f32::consts::TAU / 36.0; + let px = a * angle.cos(); + let py = b * angle.sin(); + let q = rust_closest_point_ellipse([px, py], [a, b]); + let dist = ((q[0] - px).powi(2) + (q[1] - py).powi(2)).sqrt(); + assert!( + dist < 0.2, + "Point on ellipse at angle {angle} failed: px={px}, py={py}, q={:?}, dist={dist}", + q + ); + // Verify q is on the ellipse (x/a)^2 + (y/b)^2 == 1 + let eq = (q[0] / a).powi(2) + (q[1] / b).powi(2); + assert!((eq - 1.0).abs() < 0.01, "eq={eq} for angle {angle}"); + } + + // Test points offset from ellipse (inside and outside) + for i in 0..36 { + let angle = (i as f32) * std::f32::consts::TAU / 36.0; + for offset in [-5.0f32, -1.0, 1.0, 5.0] { + let norm = [angle.cos() / a, angle.sin() / b]; + let norm_len = (norm[0] * norm[0] + norm[1] * norm[1]).sqrt(); + let un = [norm[0] / norm_len, norm[1] / norm_len]; + let px = a * angle.cos() + offset * un[0]; + let py = b * angle.sin() + offset * un[1]; + let q = rust_closest_point_ellipse([px, py], [a, b]); + let eq = (q[0] / a).powi(2) + (q[1] / b).powi(2); + assert!( + (eq - 1.0).abs() < 0.05, + "Offset point at angle {angle}, offset {offset}: q={:?}, eq={eq}", + q + ); + let measured_dist = ((q[0] - px).powi(2) + (q[1] - py).powi(2)).sqrt(); + assert!( + (measured_dist - offset.abs()).abs() < 0.2, + "Distance error at angle {angle}, offset {offset}: measured={measured_dist}" + ); + } + } + } +} diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index 90064d106..6ff34c4d0 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -408,3 +408,303 @@ fn late_device_errors_invalidate_uploaded_content_and_targets() { "the same error must not invalidate twice" ); } + +#[test] +#[ignore = "requires a GPU adapter"] +fn test_selected_circle_arc_ellipse_highlight_overlay() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + let depth = rustc_hash::FxHashMap::default(); + + let mut circle_wire = WireModel::default(); + circle_wire.name = "circle_wire".into(); + circle_wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarCircle { + center: [0.0, 0.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 10.0, + }); + + let handle = acadrust::Handle::new(100); + let mut selected_handles = rustc_hash::FxHashSet::default(); + selected_handles.insert(handle); + let hover_handles = rustc_hash::FxHashSet::default(); + + let mut index = rustc_hash::FxHashMap::default(); + index.insert(handle.value(), vec![0]); + pipeline.wire_handle_index = std::sync::Arc::new(index); + + let wires = vec![circle_wire]; + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &selected_handles, + &hover_handles, + &[], + &depth, + Some([0.0, 1.0, 1.0, 1.0]), + ); + + assert_eq!(pipeline.gpu_selected_circles.len(), 1); + assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1); + assert!(pipeline.gpu_selected_wires.is_empty()); + + // Verify classic highlight mode (SELECTIONEFFECT 0 / selected_tint: None) + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &selected_handles, + &hover_handles, + &[], + &depth, + None, + ); + assert_eq!(pipeline.gpu_selected_circles.len(), 1); + assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1); + + // Verify hover highlight + let mut circle_hover = rustc_hash::FxHashSet::default(); + circle_hover.insert(handle); + let empty_selected = rustc_hash::FxHashSet::default(); + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &empty_selected, + &circle_hover, + &[], + &depth, + None, + ); + assert_eq!(pipeline.gpu_selected_circles.len(), 1); + assert_eq!(pipeline.gpu_selected_circles[0].instance_count, 1); + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn test_selected_ellipse_overlay() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + let depth = rustc_hash::FxHashMap::default(); + + let mut ellipse_wire = WireModel::default(); + ellipse_wire.name = "ellipse_wire".into(); + ellipse_wire.tangent_geoms.push(crate::scene::model::wire_model::TangentGeom::PlanarEllipse { + center: [0.0, 0.0, 0.0], + major_axis: [10.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.5, + start_param: 0.0, + end_param: std::f64::consts::TAU, + }); + + let handle = acadrust::Handle::new(200); + let mut selected_handles = rustc_hash::FxHashSet::default(); + selected_handles.insert(handle); + let hover_handles = rustc_hash::FxHashSet::default(); + + let mut index = rustc_hash::FxHashMap::default(); + index.insert(handle.value(), vec![0]); + pipeline.wire_handle_index = std::sync::Arc::new(index); + + let wires = vec![ellipse_wire]; + // Standard tint + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &selected_handles, + &hover_handles, + &[], + &depth, + Some([0.0, 1.0, 1.0, 1.0]), + ); + + assert_eq!(pipeline.gpu_selected_ellipses.len(), 1); + assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1); + assert!(pipeline.gpu_selected_wires.is_empty()); + + // Classic highlight mode (SELECTIONEFFECT 0 / selected_tint: None) + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &selected_handles, + &hover_handles, + &[], + &depth, + None, + ); + assert_eq!(pipeline.gpu_selected_ellipses.len(), 1); + assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1); + + // Hover highlight + let mut ellipse_hover = rustc_hash::FxHashSet::default(); + ellipse_hover.insert(handle); + let empty_selected = rustc_hash::FxHashSet::default(); + pipeline.upload_selected_wires( + &device, + &queue, + &wires, + &empty_selected, + &ellipse_hover, + &[], + &depth, + None, + ); + assert_eq!(pipeline.gpu_selected_ellipses.len(), 1); + assert_eq!(pipeline.gpu_selected_ellipses[0].instance_count, 1); + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_target_ellipse"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test_encoder_ellipse"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); +} + +#[test] +#[ignore = "requires a GPU adapter"] +fn test_pline_arc_switch_preview_and_render() { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let mut pipeline = Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8UnormSrgb); + let depth = rustc_hash::FxHashMap::default(); + use crate::command::CadCommand; + + let mut cmd = crate::modules::draw::draw::polyline::PlineCommand::new(); + cmd.on_point(glam::DVec3::new(0.0, 0.0, 0.0)); + let _res = cmd.on_point(glam::DVec3::new(10.0, 0.0, 0.0)); + cmd.set_live_handle(acadrust::Handle::new(1)); + cmd.on_text_input("A"); + + let test_points = [ + glam::DVec3::new(10.0, 0.0, 0.0), + glam::DVec3::new(10.0000001, 0.0, 0.0), + glam::DVec3::new(10.0, 0.0000001, 0.0), + glam::DVec3::new(10.0, -0.0000001, 0.0), + glam::DVec3::new(9.9999999, 0.0, 0.0), + glam::DVec3::new(15.0, 5.0, 0.0), + glam::DVec3::new(10.0, 10.0, 0.0), + glam::DVec3::new(5.0, 5.0, 0.0), + glam::DVec3::new(0.0, 0.0, 0.0), + ]; + + pipeline.ensure_depth_texture(&device, iced::Size::new(512, 512)); + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("test_target"), + size: wgpu::Extent3d { + width: 512, + height: 512, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8UnormSrgb, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let target = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + for pt in test_points { + let wire = cmd.on_mouse_move(pt); + if let Some(w) = wire { + pipeline.upload_preview_wires(&device, &queue, &[w], &depth); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("test_encoder"), + }); + pipeline.render( + &mut encoder, + &target, + iced::Size::new(512, 512), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + queue.submit(Some(encoder.finish())); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + } + } +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index 908fece79..880464b12 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,6 +1,8 @@ #[cfg(test)] mod gpu_tests; mod device_capabilities; +pub mod circle_gpu; +pub mod ellipse_gpu; pub mod face3d_gpu; pub mod gpu_budget; pub mod gpu_upload; @@ -20,6 +22,8 @@ pub mod wire_gpu; use iced::wgpu; use iced::{Rectangle, Size}; +pub use circle_gpu::{CircleGpu, CircleInstance}; +pub use ellipse_gpu::{EllipseGpu, EllipseInstance}; pub use face3d_gpu::Face3DGpu; pub use wipeout_gpu::WipeoutGpu; pub use image_gpu::ImageGpu; @@ -79,6 +83,8 @@ pub struct Pipeline { shadow_pipeline: wgpu::RenderPipeline, shadow_plain_pipeline: wgpu::RenderPipeline, wire_pipeline: wgpu::RenderPipeline, + circle_pipeline: wgpu::RenderPipeline, + ellipse_pipeline: wgpu::RenderPipeline, block_wire_pipeline: wgpu::RenderPipeline, /// Stamps clip-boundary polygons into the stencil buffer (viewports + XCLIP). clip_mask_pipeline: wgpu::RenderPipeline, @@ -89,6 +95,8 @@ pub struct Pipeline { /// Same shader as wire_pipeline but depth_compare=Greater, depth_write_enabled=false. /// Used to draw ghost copies of selected wires through occluding geometry. wire_xray_pipeline: wgpu::RenderPipeline, + circle_xray_pipeline: wgpu::RenderPipeline, + ellipse_xray_pipeline: wgpu::RenderPipeline, block_wire_xray_pipeline: wgpu::RenderPipeline, /// Layout for the per-wire `WireConst` storage buffer (group 1 of the wire / /// xray pipelines). `Some` on any storage-capable device; `None` in packed @@ -225,6 +233,8 @@ pub struct Pipeline { /// replace this thin draw-range list on camera changes without touching the /// shared resident buffer. pub(crate) gpu_wires: std::sync::Arc>, + pub(crate) gpu_circles: std::sync::Arc>, + pub(crate) gpu_ellipses: std::sync::Arc>, pub(crate) gpu_block_wires: std::sync::Arc>, /// Persistent per-entity wire instance arena (capability-selected format). /// When active, `gpu_wires` is a thin wrapper over this arena's buffers and an @@ -241,6 +251,11 @@ pub struct Pipeline { pub(crate) wire_arena_fallback_handles: rustc_hash::FxHashSet, /// The Model content id both arenas currently mirror (`u64::MAX` = none). pub(crate) wire_arena_id: u64, + /// Handles that contributed to this slot's retained analytical uploads. + pub(crate) partition_contributors: rustc_hash::FxHashSet, + /// The draw-depth generation those uploads baked. A full depth rebuild + /// reassigns every label, so they stop being reusable when it moves. + pub(crate) partition_depth_generation: u64, /// Last content/camera/viewport tuple used to derive visible instance /// ranges from the resident arena. pub(crate) wire_cull_key: (u64, u64, u32, u32), @@ -256,11 +271,15 @@ pub struct Pipeline { clip_boundary: Option<(wgpu::Buffer, u32)>, /// Ghost copies (25% alpha) of selected wires for the X-ray depth pass. gpu_selected_wires: Vec, + gpu_selected_circles: Vec, + gpu_selected_ellipses: Vec, gpu_selected_block_wires: Vec, /// Command-preview / interim / grip-drag overlay wires. Re-uploaded every /// frame they are present (small), drawn on top of the base wire pass — so /// a live drag never re-uploads the resident base buffer. gpu_preview_wires: Vec, + gpu_preview_circles: Vec, + gpu_preview_ellipses: Vec, /// Wipeout masks — solid fills rendered after wires in a separate pass via /// the legacy per-primitive `WipeoutGpu` renderer. gpu_wipeouts: Vec, @@ -2083,6 +2102,22 @@ impl Pipeline { &content_stencil, ); + let (circle_pipeline, circle_xray_pipeline) = circle_gpu::create_pipelines( + device, + &frame_bgl, + format, + MSAA_SAMPLES, + &content_stencil, + ); + + let (ellipse_pipeline, ellipse_xray_pipeline) = ellipse_gpu::create_pipelines( + device, + &frame_bgl, + format, + MSAA_SAMPLES, + &content_stencil, + ); + let viewcube = ViewCubePipeline::new(device, queue, format); let init_size = Size::new(1, 1); @@ -2223,6 +2258,10 @@ impl Pipeline { wire_black_pipeline, block_wire_black_pipeline, wire_xray_pipeline, + circle_pipeline, + circle_xray_pipeline, + ellipse_pipeline, + ellipse_xray_pipeline, block_wire_xray_pipeline, wire_const_bgl, block_wire_const_bgl, @@ -2295,6 +2334,8 @@ impl Pipeline { blit_uniform_buffer, surface_format: format, gpu_wires: std::sync::Arc::new(vec![]), + gpu_circles: std::sync::Arc::new(vec![]), + gpu_ellipses: std::sync::Arc::new(vec![]), gpu_block_wires: std::sync::Arc::new(vec![]), wire_arena: None, wire_arena_mesh: None, @@ -2302,14 +2343,20 @@ impl Pipeline { wire_arena_fallback_kind: None, wire_arena_fallback_handles: rustc_hash::FxHashSet::default(), wire_arena_id: u64::MAX, + partition_contributors: rustc_hash::FxHashSet::default(), + partition_depth_generation: u64::MAX, wire_cull_key: (u64::MAX, u64::MAX, 0, 0), hatch_lod_key: (usize::MAX, u64::MAX, 0, 0, false), wipeout_lod_key: (usize::MAX, u64::MAX, 0, 0, false), silhouette_key: (usize::MAX, u64::MAX, [u32::MAX; 3], false), clip_boundary: None, gpu_selected_wires: vec![], + gpu_selected_circles: vec![], + gpu_selected_ellipses: vec![], gpu_selected_block_wires: vec![], gpu_preview_wires: vec![], + gpu_preview_circles: vec![], + gpu_preview_ellipses: vec![], gpu_wipeouts: vec![], wipeout_skip_flags: vec![], gpu_images: vec![], @@ -2371,6 +2418,86 @@ impl Pipeline { ) } + /// Upload GPU-instanced analytical circles from a wire slice. + pub fn upload_circles( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + wires: &[WireModel], + depth_map: &rustc_hash::FxHashMap, + ) -> Vec { + let mut instances: Vec = Vec::new(); + for wire in wires { + if !wire.display_visible { + continue; + } + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { + instances.extend(insts); + } + } + self.upload_circles_from_instances(device, queue, &instances) + } + + /// Upload GPU-instanced analytical circles from pre-extracted instances. + pub fn upload_circles_from_instances( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + instances: &[CircleInstance], + ) -> Vec { + if instances.is_empty() { + vec![] + } else { + vec![CircleGpu::from_instances( + device, + queue, + "viewer.circles", + instances, + )] + } + } + + /// Upload GPU-instanced analytical ellipses from a wire slice. + pub fn upload_ellipses( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + wires: &[WireModel], + depth_map: &rustc_hash::FxHashMap, + ) -> Vec { + let mut instances: Vec = Vec::new(); + for wire in wires { + if !wire.display_visible { + continue; + } + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { + instances.extend(insts); + } + } + self.upload_ellipses_from_instances(device, queue, &instances) + } + + /// Upload GPU-instanced analytical ellipses from pre-extracted instances. + pub fn upload_ellipses_from_instances( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + instances: &[EllipseInstance], + ) -> Vec { + if instances.is_empty() { + vec![] + } else { + vec![EllipseGpu::from_instances( + device, + queue, + "viewer.ellipses", + instances, + )] + } + } + /// Build resident batches and a handle index shared across viewport slots. pub fn build_wire_buffers( &mut self, @@ -2383,6 +2510,8 @@ impl Pipeline { std::sync::Arc>, std::sync::Arc>, std::sync::Arc>>, + std::sync::Arc>, + std::sync::Arc>, ) { // Batch the wire pass: instead of one GPU buffer + one draw call per // WireModel (tens of thousands on a large drawing), merge maximal runs @@ -2392,40 +2521,42 @@ impl Pipeline { // wire order — already sorted by draw order — is preserved; depth bias // and alpha blending both depend on it. Scissor and mesh-edge stay // grouping keys because the draw loop sets one scissor per batch and - // skips whole mesh-edge batches in shaded modes. - let is_mesh_edge = |w: &WireModel| !w.points.is_empty() && w.fill_is_3d; let mut batches: Vec = Vec::new(); let mut block_wires: Vec<&WireModel> = Vec::new(); let mut i = 0; while i < wires.len() { - if !wires[i].display_visible { - i += 1; - continue; + let kind = wire_arena::classify_wire(&wires[i]); + match kind { + wire_arena::WireKind::Invisible + | wire_arena::WireKind::Circle + | wire_arena::WireKind::Ellipse + | wire_arena::WireKind::Empty => { + i += 1; + continue; + } + wire_arena::WireKind::Block => { + block_wires.push(&wires[i]); + i += 1; + continue; + } + wire_arena::WireKind::Regular | wire_arena::WireKind::MeshEdge => { + let mesh_edge = kind == wire_arena::WireKind::MeshEdge; + let mut j = i + 1; + while j < wires.len() && wire_arena::classify_wire(&wires[j]) == kind { + j += 1; + } + let refs: Vec<&WireModel> = wires[i..j].iter().collect(); + batches.extend(WireGpu::from_run_refs( + device, + queue, + &refs, + depth_map, + mesh_edge, + self.wire_const_bgl.as_ref(), + )); + i = j; + } } - if wires[i].render_instance.is_some() { - block_wires.push(&wires[i]); - i += 1; - continue; - } - let mesh_edge = is_mesh_edge(&wires[i]); - let mut j = i + 1; - while j < wires.len() - && wires[j].display_visible - && wires[j].render_instance.is_none() - && is_mesh_edge(&wires[j]) == mesh_edge - { - j += 1; - } - let refs: Vec<&WireModel> = wires[i..j].iter().collect(); - batches.extend(WireGpu::from_run_refs( - device, - queue, - &refs, - depth_map, - mesh_edge, - self.wire_const_bgl.as_ref(), - )); - i = j; } let block_batches = BlockWireGpu::from_wires( device, @@ -2462,10 +2593,14 @@ impl Pipeline { wires.len(), ); } + let circle_batches = self.upload_circles(device, queue, wires, depth_map); + let ellipse_batches = self.upload_ellipses(device, queue, wires, depth_map); ( std::sync::Arc::new(batches), std::sync::Arc::new(block_batches), std::sync::Arc::new(index), + std::sync::Arc::new(circle_batches), + std::sync::Arc::new(ellipse_batches), ) } @@ -2492,6 +2627,8 @@ impl Pipeline { if selected.is_empty() && hovered.is_empty() && annotation_context_wires.is_empty() { self.gpu_selected_wires = vec![]; self.gpu_selected_block_wires = vec![]; + self.gpu_selected_circles = vec![]; + self.gpu_selected_ellipses = vec![]; return; } // Gather borrowed highlighted wires via the prebuilt index — @@ -2504,6 +2641,9 @@ impl Pipeline { slots.sort_unstable(); for &i in &slots { if let Some(w) = wires.get(i as usize) { + if !w.display_visible { + continue; + } selected_wires.push(w); } } @@ -2515,27 +2655,103 @@ impl Pipeline { slots.sort_unstable(); for &i in &slots { if let Some(w) = wires.get(i as usize) { + if !w.display_visible { + continue; + } hover_wires.push(w); } } } } for wire in annotation_context_wires { + if !wire.display_visible { + continue; + } if wire.selected { selected_wires.push(wire); } else { hover_wires.push(wire); } } + let mut selected_circles: Vec = Vec::new(); + let circle_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED); + for &wire in &selected_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { + for mut inst in insts { + inst.color = circle_sel_tint; + selected_circles.push(inst); + } + } + } + for &wire in &hover_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = circle_gpu::extract_circle_instances(wire, depth) { + for mut inst in insts { + inst.color = WireModel::HOVER; + selected_circles.push(inst); + } + } + } + self.gpu_selected_circles = if selected_circles.is_empty() { + vec![] + } else { + vec![CircleGpu::from_instances( + device, + queue, + "viewer.selected_circles", + &selected_circles, + )] + }; + + let mut selected_ellipses: Vec = Vec::new(); + let ellipse_sel_tint = selected_tint.unwrap_or(WireModel::SELECTED); + for &wire in &selected_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { + for mut inst in insts { + inst.color = ellipse_sel_tint; + selected_ellipses.push(inst); + } + } + } + for &wire in &hover_wires { + let depth = wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = ellipse_gpu::extract_ellipse_instances(wire, depth) { + for mut inst in insts { + inst.color = WireModel::HOVER; + selected_ellipses.push(inst); + } + } + } + self.gpu_selected_ellipses = if selected_ellipses.is_empty() { + vec![] + } else { + vec![EllipseGpu::from_instances( + device, + queue, + "viewer.selected_ellipses", + &selected_ellipses, + )] + }; + let selected_regular: Vec<&WireModel> = selected_wires .iter() .copied() - .filter(|wire| wire.render_instance.is_none()) + .filter(|wire| { + wire.render_instance.is_none() + && circle_gpu::extract_circle_instances(wire, 0.0).is_none() + && ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none() + }) .collect(); let hover_regular: Vec<&WireModel> = hover_wires .iter() .copied() - .filter(|wire| wire.render_instance.is_none()) + .filter(|wire| { + wire.render_instance.is_none() + && circle_gpu::extract_circle_instances(wire, 0.0).is_none() + && ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_none() + }) .collect(); let selected_blocks: Vec<&WireModel> = selected_wires .iter() @@ -2742,11 +2958,47 @@ impl Pipeline { wires: &[WireModel], depth_map: &rustc_hash::FxHashMap, ) { - self.gpu_preview_wires = if wires.is_empty() { - vec![] + if wires.is_empty() { + self.gpu_preview_wires.clear(); + self.gpu_preview_circles.clear(); + self.gpu_preview_ellipses.clear(); } else { - WireGpu::from_run(device, queue, wires, depth_map, false, self.wire_const_bgl.as_ref()) - }; + let partitioned = wire_arena::partition_wires(wires, depth_map); + let mut non_analytical = partitioned.regular; + non_analytical.extend(partitioned.mesh); + self.gpu_preview_wires = if non_analytical.is_empty() { + vec![] + } else { + WireGpu::from_run_refs( + device, + queue, + &non_analytical, + depth_map, + false, + self.wire_const_bgl.as_ref(), + ) + }; + self.gpu_preview_circles = if partitioned.circle_instances.is_empty() { + vec![] + } else { + vec![CircleGpu::from_instances( + device, + queue, + "preview.circles", + &partitioned.circle_instances, + )] + }; + self.gpu_preview_ellipses = if partitioned.ellipse_instances.is_empty() { + vec![] + } else { + vec![EllipseGpu::from_instances( + device, + queue, + "preview.ellipses", + &partitioned.ellipse_instances, + )] + }; + } } /// Upload the live grip-drag / command-preview SDF glyph quads. Re-uploaded @@ -4373,6 +4625,26 @@ impl Pipeline { } bind_and_draw_block_wire(&mut pass, wire); } + // Analytical GPU circles (instanced screen-space quads) + if self.gpu_circles.iter().any(|cg| cg.instance_count > 0) { + pass.set_pipeline(&self.circle_pipeline); + for cg in self.gpu_circles.iter() { + if cg.instance_count > 0 { + pass.set_vertex_buffer(0, cg.instance_buffer.slice(..)); + pass.draw(0..6, 0..cg.instance_count); + } + } + } + // Analytical GPU ellipses (instanced screen-space quads) + if self.gpu_ellipses.iter().any(|eg| eg.instance_count > 0) { + pass.set_pipeline(&self.ellipse_pipeline); + for eg in self.gpu_ellipses.iter() { + if eg.instance_count > 0 { + pass.set_vertex_buffer(0, eg.instance_buffer.slice(..)); + pass.draw(0..6, 0..eg.instance_count); + } + } + } // Live overlay wires (command preview / interim / grip drag) always // on top: the xray pipeline (depth_compare=Always, no depth write) // keeps them visible through any occluding geometry — a 3D solid, or @@ -4393,6 +4665,24 @@ impl Pipeline { } } } + if self.gpu_preview_circles.iter().any(|cg| cg.instance_count > 0) { + pass.set_pipeline(&self.circle_xray_pipeline); + for cg in &self.gpu_preview_circles { + if cg.instance_count > 0 { + pass.set_vertex_buffer(0, cg.instance_buffer.slice(..)); + pass.draw(0..6, 0..cg.instance_count); + } + } + } + if self.gpu_preview_ellipses.iter().any(|eg| eg.instance_count > 0) { + pass.set_pipeline(&self.ellipse_xray_pipeline); + for eg in &self.gpu_preview_ellipses { + if eg.instance_count > 0 { + pass.set_vertex_buffer(0, eg.instance_buffer.slice(..)); + pass.draw(0..6, 0..eg.instance_count); + } + } + } } // ── Pass 5c: SDF text quads (drawn over wires) ──────────────────── @@ -4503,6 +4793,8 @@ impl Pipeline { || !self.block_text_highlight_gpu.is_empty(); if !self.gpu_selected_wires.is_empty() || !self.gpu_selected_block_wires.is_empty() + || !self.gpu_selected_circles.is_empty() + || !self.gpu_selected_ellipses.is_empty() || have_text_highlight { let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { @@ -4563,6 +4855,34 @@ impl Pipeline { bind_and_draw_block_wire(&mut pass, wire); } } + if !self.gpu_selected_circles.is_empty() { + pass.set_pipeline(if hidden_line { + &self.circle_pipeline + } else { + &self.circle_xray_pipeline + }); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + for cg in &self.gpu_selected_circles { + if cg.instance_count > 0 { + pass.set_vertex_buffer(0, cg.instance_buffer.slice(..)); + pass.draw(0..6, 0..cg.instance_count); + } + } + } + if !self.gpu_selected_ellipses.is_empty() { + pass.set_pipeline(if hidden_line { + &self.ellipse_pipeline + } else { + &self.ellipse_xray_pipeline + }); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + for eg in &self.gpu_selected_ellipses { + if eg.instance_count > 0 { + pass.set_vertex_buffer(0, eg.instance_buffer.slice(..)); + pass.draw(0..6, 0..eg.instance_count); + } + } + } if let Some(atlas) = &self.text_atlas_gpu { if !self.text_highlight_gpu.is_empty() { pass.set_pipeline(&self.text_highlight_pipeline); @@ -4648,12 +4968,7 @@ impl Pipeline { } } - /// Forget every cache key that describes this slot's uploaded content. - /// - /// Shared by slot reuse and by [`Self::release_heavy_resources`] so the two - /// cannot disagree about what "this slot holds nothing you can trust" - /// means. Keys only — the buffers themselves are dropped by the caller that - /// wants the memory back. + /// Reset every cache key that describes this slot's uploaded content. pub(crate) fn forget_cached_keys(&mut self) { self.cached_epoch = (u64::MAX, u64::MAX, u64::MAX); self.cached_wire_id = u64::MAX; @@ -4676,6 +4991,9 @@ impl Pipeline { self.silhouette_key = (usize::MAX, u64::MAX, [u32::MAX; 3], false); self.silhouette_source_key = (usize::MAX, usize::MAX, u64::MAX); self.render_sig = u64::MAX; + // The retained-upload record is part of the slot's cached state. + self.partition_contributors.clear(); + self.partition_depth_generation = u64::MAX; } /// Catch errors delivered after the previous upload/submit completed. @@ -4689,6 +5007,8 @@ impl Pipeline { self.wire_arena = None; self.wire_arena_mesh = None; self.wire_arena_id = u64::MAX; + self.partition_contributors.clear(); + self.partition_depth_generation = u64::MAX; self.alloc_size = Size::new(0, 0); self.shadow_full = None; self.background_source_id = usize::MAX; @@ -4711,6 +5031,8 @@ impl Pipeline { self.gpu_wires = std::sync::Arc::new(Vec::new()); self.gpu_block_wires = std::sync::Arc::new(Vec::new()); + self.gpu_circles = std::sync::Arc::new(Vec::new()); + self.gpu_ellipses = std::sync::Arc::new(Vec::new()); self.wire_handle_index = std::sync::Arc::new(rustc_hash::FxHashMap::default()); self.wire_arena = None; self.wire_arena_mesh = None; @@ -4718,10 +5040,16 @@ impl Pipeline { self.wire_arena_fallback_kind = None; self.wire_arena_fallback_handles.clear(); self.wire_arena_id = u64::MAX; + self.partition_contributors.clear(); + self.partition_depth_generation = u64::MAX; self.gpu_selected_wires.clear(); self.gpu_selected_block_wires.clear(); + self.gpu_selected_circles.clear(); + self.gpu_selected_ellipses.clear(); self.gpu_preview_wires.clear(); + self.gpu_preview_circles.clear(); + self.gpu_preview_ellipses.clear(); self.hatch_gpu.clear(); self.gpu_wipeouts.clear(); self.wipeout_skip_flags.clear(); @@ -4990,6 +5318,7 @@ impl MultiPipeline { self.wire_buffer_cache.retain(|_, entry| { std::sync::Arc::strong_count(&entry.0) > 1 || std::sync::Arc::strong_count(&entry.1) > 1 + || std::sync::Arc::strong_count(&entry.3) > 1 }); self.block_geometry.retain(|_, chunks| { chunks.iter().any(|chunk| std::sync::Arc::strong_count(&chunk.bind_group) > 1) @@ -5140,6 +5469,8 @@ pub struct MultiPipeline { std::sync::Arc>, std::sync::Arc>, std::sync::Arc>>, + std::sync::Arc>, + std::sync::Arc>, ), >, } diff --git a/src/scene/pipeline/wire_arena.rs b/src/scene/pipeline/wire_arena.rs index 4fe85fab0..265ef257d 100644 --- a/src/scene/pipeline/wire_arena.rs +++ b/src/scene/pipeline/wire_arena.rs @@ -120,15 +120,152 @@ fn handle_of(w: &WireModel) -> Option { crate::scene::Scene::handle_from_wire_name(&w.name) } +/// Classification of a wire model for rendering pipelines. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WireKind { + Invisible, + Block, + Circle, + Ellipse, + Regular, + MeshEdge, + Empty, +} + +/// Fast, branch-predicted classification of a wire model. +/// +/// Bypasses analytical circle/ellipse extraction for the 95%+ of wires that have +/// no tangent geometry or multiple tangent primitives, reducing overhead to ~2 ns. +#[inline] +pub fn classify_wire(wire: &WireModel) -> WireKind { + if !wire.display_visible { + return WireKind::Invisible; + } + if wire.render_instance.is_some() { + return WireKind::Block; + } + if !wire.tangent_geoms.is_empty() + && wire.fill_tris.is_empty() + && wire.pick_tris.is_empty() + && wire.text_verts.is_empty() + { + if super::circle_gpu::extract_circle_instances(wire, 0.0).is_some() { + return WireKind::Circle; + } + if super::ellipse_gpu::extract_ellipse_instances(wire, 0.0).is_some() { + return WireKind::Ellipse; + } + } + if !wire.points.is_empty() { + if wire.fill_is_3d { + WireKind::MeshEdge + } else { + WireKind::Regular + } + } else { + WireKind::Empty + } +} + +/// Result of a single-pass partitioning of viewport wires. +pub struct PartitionedWires<'a> { + pub regular: Vec<&'a WireModel>, + pub mesh: Vec<&'a WireModel>, + pub instanced: Vec<&'a WireModel>, + pub circle_instances: Vec, + pub ellipse_instances: Vec, + /// Handles that contributed to the retained analytical uploads. + pub contributors: rustc_hash::FxHashSet, +} + +/// Whether this wire contributes to an upload retained across arena patches. +pub fn feeds_analytical_uploads(wire: &WireModel) -> bool { + matches!( + classify_wire(wire), + WireKind::Block | WireKind::Circle | WireKind::Ellipse + ) +} + +/// Single-pass classification and extraction of all viewport wire categories. +/// +/// Traverses the wire slice exactly once, eliminating redundant passes and duplicate +/// analytical extractions across split_wires, upload_block_wires, upload_circles, +/// and upload_ellipses. +pub fn partition_wires<'a>( + wires: &'a [WireModel], + depth_map: &rustc_hash::FxHashMap, +) -> PartitionedWires<'a> { + let mut regular = Vec::new(); + let mut mesh = Vec::new(); + let mut instanced = Vec::new(); + let mut circle_instances = Vec::new(); + let mut ellipse_instances = Vec::new(); + let mut contributors: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); + let note = |wire: &WireModel, set: &mut rustc_hash::FxHashSet| { + if let Some(handle) = handle_of(wire) { + set.insert(handle); + } + }; + + for wire in wires { + if !wire.display_visible { + continue; + } + if wire.render_instance.is_some() { + note(wire, &mut contributors); + instanced.push(wire); + continue; + } + // Resolve draw depth only for wires eligible for analytical extraction. + if !wire.tangent_geoms.is_empty() + && wire.fill_tris.is_empty() + && wire.pick_tris.is_empty() + && wire.text_verts.is_empty() + { + let depth = super::wire_gpu::wire_draw_depth(wire, depth_map); + if let Some(insts) = super::circle_gpu::extract_circle_instances(wire, depth) { + note(wire, &mut contributors); + circle_instances.extend(insts); + continue; + } + if let Some(insts) = super::ellipse_gpu::extract_ellipse_instances(wire, depth) { + note(wire, &mut contributors); + ellipse_instances.extend(insts); + continue; + } + } + if !wire.points.is_empty() { + if wire.fill_is_3d { + mesh.push(wire); + } else { + regular.push(wire); + } + } + } + + PartitionedWires { + regular, + mesh, + instanced, + circle_instances, + ellipse_instances, + contributors, + } +} + /// Use the same regular/mesh partition for full uploads and changed runs. /// Instanced blocks are uploaded separately by `BlockWireGpu`. pub fn split_wires(wires: &[WireModel]) -> (Vec<&WireModel>, Vec<&WireModel>) { - wires - .iter() - .filter(|wire| { - wire.display_visible && !wire.points.is_empty() && wire.render_instance.is_none() - }) - .partition(|wire| !wire.fill_is_3d) + let mut regular = Vec::new(); + let mut mesh = Vec::new(); + for wire in wires { + match classify_wire(wire) { + WireKind::Regular => regular.push(wire), + WireKind::MeshEdge => mesh.push(wire), + _ => {} + } + } + (regular, mesh) } /// True when appending a new entity at the tail could change the image, so the @@ -1528,4 +1665,48 @@ mod tests { assert!(blank_const().pattern_length < 0.0); assert!(blank_packed_instance().pattern_length < 0.0); } + + #[test] + fn partition_wires_partitions_preview_analytical_curves() { + use crate::scene::model::wire_model::TangentGeom; + let mut circle_preview = WireModel::default(); + circle_preview.tangent_geoms.push(TangentGeom::PlanarCircle { + center: [1.0, 2.0, 3.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 10.0, + }); + + let mut arc_preview = WireModel::default(); + arc_preview.tangent_geoms.push(TangentGeom::Arc { + center: [4.0, 5.0, 6.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 8.0, + start_angle: 0.1, + end_angle: 2.0, + }); + + let mut ellipse_preview = WireModel::default(); + ellipse_preview.tangent_geoms.push(TangentGeom::PlanarEllipse { + center: [7.0, 8.0, 9.0], + major_axis: [5.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.6, + start_param: 0.0, + end_param: std::f64::consts::TAU, + }); + + let mut regular_line = WireModel::default(); + regular_line.points.push([0.0, 0.0, 0.0]); + regular_line.points.push([1.0, 1.0, 1.0]); + + let wires = vec![circle_preview, arc_preview, ellipse_preview, regular_line]; + let depth_map = rustc_hash::FxHashMap::default(); + let partitioned = partition_wires(&wires, &depth_map); + + assert_eq!(partitioned.circle_instances.len(), 2); + assert_eq!(partitioned.ellipse_instances.len(), 1); + assert_eq!(partitioned.regular.len(), 1); + } } diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 590635f04..d77e6d88a 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -360,19 +360,62 @@ impl Scene { self.selected.len() } - /// Sorted entity types in the current layout, cached until geometry or layout changes. + /// Entity type names in the current layout for the selection-filter menu. + /// Pure additions are folded into the cached set; other changes rebuild it. pub fn entity_type_names_in_layout(&self) -> std::sync::Arc> { use crate::entities::traits::entity_type_name; let block = self.current_layout_block_handle(); + let mut cached_epoch = None; { let cache = self.layout_type_names_cache.borrow(); - if let Some((epoch, cached_block, names)) = cache.as_ref() { - if *epoch == self.geometry_epoch && *cached_block == block { - return std::sync::Arc::clone(names); + if let Some((epoch, cached_block, _, names)) = cache.as_ref() { + if *cached_block == block { + if *epoch == self.geometry_epoch { + return std::sync::Arc::clone(names); + } + cached_epoch = Some(*epoch); } } } - let mut names: std::collections::BTreeSet<&str> = + + // Incremental: fold the changes since the cached epoch into the set. + if let Some(since) = cached_epoch { + if let Some(deltas) = self.replay_since(since) { + if deltas + .iter() + .all(|(_, kind)| *kind == ChangeKind::Added) + { + let mut cache = self.layout_type_names_cache.borrow_mut(); + if let Some((epoch, _, present, names)) = cache.as_mut() { + let mut added = false; + for (handle, _) in &deltas { + if let Some(entity) = self.document.get_entity(*handle) { + if entity.common().owner_handle == block { + // `contains` first: the common case is a + // type already present, and that path must + // not allocate. + let name = entity_type_name(entity); + if !present.contains(name) { + present.insert(name.to_string()); + added = true; + } + } + } + } + // Only rebuild the list when the set actually moved; + // otherwise the existing `Arc` is still the answer. + if added { + *names = + std::sync::Arc::new(present.iter().cloned().collect()); + } + *epoch = self.geometry_epoch; + return std::sync::Arc::clone(names); + } + } + } + } + + let mut present: std::collections::BTreeSet = std::collections::BTreeSet::new(); if let Some(record) = self .document @@ -382,15 +425,21 @@ impl Scene { { for &handle in &record.entity_handles { if let Some(entity) = self.document.get_entity(handle) { - names.insert(entity_type_name(entity)); + let name = entity_type_name(entity); + if !present.contains(name) { + present.insert(name.to_string()); + } } } } - let names: std::sync::Arc> = std::sync::Arc::new( - names.into_iter().map(str::to_string).collect(), - ); - *self.layout_type_names_cache.borrow_mut() = - Some((self.geometry_epoch, block, std::sync::Arc::clone(&names))); + let names: std::sync::Arc> = + std::sync::Arc::new(present.iter().cloned().collect()); + *self.layout_type_names_cache.borrow_mut() = Some(( + self.geometry_epoch, + block, + present, + std::sync::Arc::clone(&names), + )); names } @@ -954,6 +1003,60 @@ impl Scene { mod tests { use super::*; + #[test] + fn adding_a_type_already_present_reuses_the_list() { + use acadrust::entities::{Circle, EntityType, Line}; + use acadrust::types::Vector3; + use std::sync::Arc; + + let line = || { + EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + )) + }; + let mut scene = Scene::new(); + scene.add_entity(line()); + let first = scene.entity_type_names_in_layout(); + + scene.add_entity(line()); + let second = scene.entity_type_names_in_layout(); + assert!( + Arc::ptr_eq(&first, &second), + "a second line changes no type name, so the list must be reused", + ); + + scene.add_entity(EntityType::Circle(Circle::new())); + let third = scene.entity_type_names_in_layout(); + assert_eq!(third.as_slice(), ["Circle", "Line"], "a new type must appear"); + + // The incremental answer has to be the answer a full walk gives. + scene.layout_type_names_cache.borrow_mut().take(); + assert_eq!( + scene.entity_type_names_in_layout().as_slice(), + third.as_slice(), + "folding edits in must match rebuilding from scratch", + ); + } + + #[test] + fn changing_an_entity_type_rebuilds_the_type_names() { + use acadrust::entities::{Circle, EntityType, Line}; + use acadrust::types::Vector3; + + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::Line(Line::from_points( + Vector3::new(0.0, 0.0, 0.0), + Vector3::new(1.0, 0.0, 0.0), + ))); + assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Line"]); + + let mut circle = Circle::new(); + circle.common.handle = handle; + assert!(scene.update_entity(EntityType::Circle(circle))); + assert_eq!(scene.entity_type_names_in_layout().as_slice(), ["Circle"]); + } + #[test] fn layout_type_cache_reuses_and_invalidates_on_edits_undo_and_layout() { use acadrust::entities::{Circle, EntityType, Line}; diff --git a/src/scene/text/sdf_atlas.rs b/src/scene/text/sdf_atlas.rs index b9bdd56e6..dc87acdfa 100644 --- a/src/scene/text/sdf_atlas.rs +++ b/src/scene/text/sdf_atlas.rs @@ -391,8 +391,13 @@ impl GlyphAtlas { let (fw, fh) = (self.width as f32, self.height as f32); Some(AtlasEntry { - uv_min: [ox as f32 / fw, oy as f32 / fh], - uv_max: [(ox + tile.w) as f32 / fw, (oy + tile.h) as f32 / fh], + // Sample the centers of the edge texels rather than the tile boundaries. + // This prevents bilinear filtering from bleeding into neighbouring tiles. + uv_min: [(ox as f32 + 0.5) / fw, (oy as f32 + 0.5) / fh], + uv_max: [ + ((ox + tile.w) as f32 - 0.5) / fw, + ((oy + tile.h) as f32 - 0.5) / fh, + ], plane_min: tile.plane_min, plane_max: tile.plane_max, advance: tile.advance, diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 72fd2b92d..331a536f5 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -149,6 +149,9 @@ pub struct ViewportData { /// by the wire / face3d pipelines as a clip-z bias. WireModels carry no /// depth field (84 construction sites); the bias is looked up by handle /// at GPU-upload time from this map instead. + /// See `Scene::draw_depth_generation`. Cached uploads that bake a depth are + /// only reusable while this is unchanged. + pub(in crate::scene) draw_depth_generation: u64, pub(in crate::scene) draw_depths: std::sync::Weak>, pub(in crate::scene) hatches: Arc>, @@ -839,20 +842,83 @@ impl shader::Primitive for Primitive { gpus.extend(arena.wire_gpus()); } inner.gpu_wires = std::sync::Arc::new(gpus); - // Blocks keep their own cached geometry and placements. - let instanced: Vec<&crate::scene::WireModel> = vp_wires - .iter() - .filter(|w| w.render_instance.is_some()) - .collect(); - inner.gpu_block_wires = std::sync::Arc::new( - inner.upload_block_wires( - device, - queue, - &instanced, - &draw_depths, - &mut pipeline.block_geometry, - ), - ); + // Retain analytical uploads when a patch changes neither + // their contributors nor their baked draw depths. + let analytical_untouched = _patched + && inner.partition_depth_generation == vp.draw_depth_generation + && patch.is_some_and(|patch| { + patch.changes.iter().all(|(handle, _)| { + !inner.partition_contributors.contains(handle) + && !patch.runs.get(handle).is_some_and(|run| { + run.iter() + .any(wire_arena::feeds_analytical_uploads) + }) + }) + }); + let t_part = _perf.then(iced::time::Instant::now); + let partitioned = (!analytical_untouched) + .then(|| wire_arena::partition_wires(&vp_wires, &draw_depths)); + let part_ms = t_part + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_blk = _perf.then(iced::time::Instant::now); + let mut blk_ms = 0.0f64; + let mut curve_ms = 0.0f64; + // `None` means the three uploads this slot holds are + // still the answer, so they are left alone. + if let Some(partitioned) = &partitioned { + inner.gpu_block_wires = std::sync::Arc::new( + inner.upload_block_wires( + device, + queue, + &partitioned.instanced, + &draw_depths, + &mut pipeline.block_geometry, + ), + ); + blk_ms = t_blk + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + let t_curve = _perf.then(iced::time::Instant::now); + inner.gpu_circles = std::sync::Arc::new( + inner.upload_circles_from_instances( + device, + queue, + &partitioned.circle_instances, + ), + ); + inner.gpu_ellipses = std::sync::Arc::new( + inner.upload_ellipses_from_instances( + device, + queue, + &partitioned.ellipse_instances, + ), + ); + inner + .partition_contributors + .clone_from(&partitioned.contributors); + inner.partition_depth_generation = vp.draw_depth_generation; + curve_ms = t_curve + .map_or(0.0, |t| t.elapsed().as_secs_f64() * 1000.0); + } + if _perf { + // Report counts only when partitioning ran. + match &partitioned { + Some(partitioned) => crate::perf_record!( + "[perf] arena-post partition={part_ms:.1}ms \ +blocks={blk_ms:.1}ms curves={curve_ms:.1}ms skipped=false wires={} instanced={} \ +circles={} ellipses={}", + vp_wires.len(), + partitioned.instanced.len(), + partitioned.circle_instances.len(), + partitioned.ellipse_instances.len(), + ), + None => crate::perf_record!( + "[perf] arena-post skipped=true wires={} \ +retained_contributors={}", + vp_wires.len(), + inner.partition_contributors.len(), + ), + } + } if _patched { wire_arena::patch_handle_index( &mut inner.wire_handle_index, @@ -911,10 +977,14 @@ impl shader::Primitive for Primitive { // Other panes retain their own references to shared geometry. inner.gpu_wires = std::sync::Arc::new(Vec::new()); inner.gpu_block_wires = std::sync::Arc::new(Vec::new()); + inner.gpu_circles = std::sync::Arc::new(Vec::new()); + inner.gpu_ellipses = std::sync::Arc::new(Vec::new()); let held_before = pipeline.wire_buffer_cache.len(); - pipeline.wire_buffer_cache.retain(|_, (w, b, _)| { + pipeline.wire_buffer_cache.retain(|_, (w, b, _, c, e)| { std::sync::Arc::strong_count(w) > 1 || std::sync::Arc::strong_count(b) > 1 + || std::sync::Arc::strong_count(c) > 1 + || std::sync::Arc::strong_count(e) > 1 }); if _perf { crate::perf_record!( @@ -959,6 +1029,8 @@ impl shader::Primitive for Primitive { inner.gpu_wires = built.0; inner.gpu_block_wires = built.1; inner.wire_handle_index = built.2; + inner.gpu_circles = built.3; + inner.gpu_ellipses = built.4; } // end !arena_served inner.cached_wire_id = vp.wire_content_id; if _perf { @@ -1199,8 +1271,9 @@ impl shader::Primitive for Primitive { let prepare_ms = nav_prepare_started.elapsed().as_secs_f64() * 1000.0; if let Some(sample) = self.nav_perf { crate::perf::record(format_args!( - "[perf] nav-prepare op={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}", + "[perf] nav-prepare op={} cause={} space={} mode={} input={:.2}ms build={:.2}ms prepare={:.2}ms elapsed={:.2}ms viewports={}", sample.op.label(), + sample.cause, sample.space, sample.mode, sample.input_ms, @@ -4221,6 +4294,7 @@ impl Scene { face3d_wires, text_verts, preview_text_verts, + draw_depth_generation: self.draw_depth_generation(), draw_depths: Arc::downgrade(&draw_depths), hatches, wipeout_hatches, diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl new file mode 100644 index 000000000..3e88c88aa --- /dev/null +++ b/src/shaders/circle.wgsl @@ -0,0 +1,224 @@ +// GPU-instanced analytical circle shader — renders 2D/planar circles as screen/plane-aligned quads. +// Topology: TriangleList, 6 vertices drawn per INSTANCE. +// +// One instance = one complete circle. The six vertex IDs map to the corners of a +// two-triangle quad covering the circle's bounding box (+ lineweight margin). +// +// The fragment shader analytically computes the exact distance to the circle perimeter: +// d_world = abs(length(local_xy) - radius) +// and uses screen-space derivatives (fwidth) to achieve perfect sub-pixel anti-aliasing +// at any zoom level, without any polygon faceting or CPU re-tessellation. + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + linetype_scale: f32, + lineweight_scale: f32, + // Relative-to-eye (double-single) + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +const DRAW_ORDER_BIAS: f32 = 0.001; +const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; +const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +const TAU: f32 = 6.283185307179586; + +struct InstanceIn { + @location(0) center_high: vec4, // xyz = center_high, w = unused + @location(1) center_low: vec4, // xyz = center_low, w = radius + @location(2) axis_x: vec4, // xyz = axis_x, w = start_angle + @location(3) axis_y: vec4, // xyz = axis_y, w = end_angle + @location(4) color: vec4, // rgba + @location(5) params: vec4, // half_width_px, pattern_length, draw_depth, unused + @location(6) pat0: vec4, + @location(7) pat1: vec4, +} + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) local_pos: vec2, + @location(1) radius: f32, + @location(2) hw_px: f32, + @location(3) color: vec4, + @location(4) pattern_length: f32, + @location(5) pat0: vec4, + @location(6) pat1: vec4, + @location(7) @interpolate(flat) min_elem: f32, + @location(8) @interpolate(flat) start_angle: f32, + @location(9) @interpolate(flat) end_angle: f32, +} + +fn resolve_hw(px_hw: f32) -> f32 { + var display_hw = max(px_hw * u.lineweight_scale, 0.5); + if u.lineweight_scale < 0.0 { + let scale = -u.lineweight_scale; + let base_hw = select( + min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5), + 0.5, + px_hw <= 0.5, + ); + display_hw = max(base_hw * scale, 0.5); + } + return select(0.5, display_hw, u.lwdisplay_enable > 0.5); +} + +@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut { + // Two-triangle unit quad corner table: + // vid 0,1,2 = (-1,-1) ( 1,-1) ( 1, 1) + // vid 3,4,5 = (-1,-1) ( 1, 1) (-1, 1) + let u_arr = array(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0); + let v_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let u_val = u_arr[vid]; + let v_val = v_arr[vid]; + + let center_high = in.center_high.xyz; + let center_low = in.center_low.xyz; + let radius = in.center_low.w; + let axis_x = in.axis_x.xyz; + let axis_y = in.axis_y.xyz; + let color = in.color; + let hw_px = resolve_hw(in.params.x); + let pattern_length = in.params.y; + let draw_depth = in.params.z; + + // Expand bounding quad so anti-aliasing margin and lineweight never clip + let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6); + let ext = radius + margin_world; + + let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low); + let world_pos_rel = center_rel + (u_val * axis_x + v_val * axis_y) * ext; + + var clip_pos = u.view_rot * vec4(world_pos_rel, 1.0); + clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + + var out: VertexOut; + out.clip_pos = clip_pos; + out.local_pos = vec2(u_val * ext, v_val * ext); + out.radius = radius; + out.hw_px = hw_px; + out.color = color; + out.pattern_length = pattern_length; + out.pat0 = in.pat0; + out.pat1 = in.pat1; + + // Compute smallest non-zero pattern element for LOD short-circuiting + let elems = array(in.pat0.x, in.pat0.y, in.pat0.z, in.pat0.w, in.pat1.x, in.pat1.y, in.pat1.z, in.pat1.w); + var min_elem = 1e9; + for (var i = 0u; i < 8u; i++) { + let e = abs(elems[i]); + if e > 0.0 && e < min_elem { + min_elem = e; + } + } + out.min_elem = select(0.0, min_elem, min_elem < 1e8); + + out.start_angle = in.axis_x.w; + out.end_angle = in.axis_y.w; + + return out; +} + +fn mod_tau(val: f32) -> f32 { + let m = val % TAU; + return select(m, m + TAU, m < 0.0); +} + +fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { + let elems = array(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w); + var count = 0u; + for (var i = 0u; i < 8u; i++) { + if elems[i] != 0.0 { count = i + 1u; } + } + if count == 0u { + return true; + } + + let d = ((dist % pat_len) + pat_len) % pat_len; + var pos = 0.0f; + let dot_half = u.world_per_pixel * 0.75; + for (var i = 0u; i < count; i++) { + let elem = elems[i]; + if elem == 0.0 { + let dd = abs(d - pos); + if min(dd, pat_len - dd) <= dot_half { return true; } + } else if elem > 0.0 { + if d >= pos && d < pos + elem { return true; } + pos += elem; + } else { + pos += -elem; + } + } + return false; +} + +@fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { + let r = length(in.local_pos); + + // Screen-space derivative anti-aliasing: + // Use the gradient of the monotonic radial distance `r` instead of `abs(r - radius)`. + // Taking derivatives across the cusp of abs() cancels out at scanline crossings, + // which previously collapsed fw to near-zero and punched periodic 1-2px holes (false dashes). + let grad = vec2(dpdx(r), dpdy(r)); + let grad_len = length(grad); + let fw = max(select(u.world_per_pixel, grad_len, grad_len > 1e-6), 1e-6); + + let sa = in.start_angle; + let ea = in.end_angle; + var sweep = mod_tau(ea - sa); + if sweep <= 1e-5 && abs(ea - sa) > 1e-5 { + sweep = TAU; + } + + let angle = atan2(in.local_pos.y, in.local_pos.x); + let theta = select(angle, angle + TAU, angle < 0.0); + let d_theta = mod_tau(theta - sa); + + var d_world: f32; + var arc_dist: f32; + + if sweep >= TAU - 1e-5 || d_theta <= sweep { + // Inside arc sweep or full circle + d_world = abs(r - in.radius); + arc_dist = in.radius * d_theta; + } else { + // Outside sweep: measure distance to start and end endpoints for round end caps + let p_start = in.radius * vec2(cos(sa), sin(sa)); + let p_end = in.radius * vec2(cos(ea), sin(ea)); + let d_start = length(in.local_pos - p_start); + let d_end = length(in.local_pos - p_end); + if d_start < d_end { + d_world = d_start; + arc_dist = 0.0; + } else { + d_world = d_end; + arc_dist = in.radius * sweep; + } + } + + let d_px = d_world / fw; + let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0); + if alpha_cov <= 0.0 { + discard; + } + + // Linetype dash pattern test + if in.pattern_length > 0.0 { + let pat_len = in.pattern_length * u.linetype_scale; + if in.min_elem * u.linetype_scale >= u.world_per_pixel && pat_len > 0.0 { + if !in_dash(arc_dist, pat_len, in.pat0, in.pat1) { + discard; + } + } + } + + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5) * alpha_cov; + return vec4(in.color.rgb, alpha); +} diff --git a/src/shaders/ellipse.wgsl b/src/shaders/ellipse.wgsl new file mode 100644 index 000000000..b6c35ba62 --- /dev/null +++ b/src/shaders/ellipse.wgsl @@ -0,0 +1,275 @@ +// GPU-instanced analytical ellipse shader — renders 2D/planar ellipses and elliptical arcs as quads. +// Topology: TriangleList, 6 vertices drawn per INSTANCE. +// +// Evaluates Inigo Quilez's exact 2D Ellipse Signed Distance Function (quartic closest-point solver) +// with sub-pixel anti-aliasing and parametric arc endpoint clamping for round end caps. + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + linetype_scale: f32, + lineweight_scale: f32, + // Relative-to-eye (double-single) + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +const DRAW_ORDER_BIAS: f32 = 0.001; +const MODEL_LINEWEIGHT_BOOST: f32 = 2.0; +const MODEL_LINEWEIGHT_MAX_PX: f32 = 10.0; +const TAU: f32 = 6.283185307179586; + +struct InstanceIn { + @location(0) center_high: vec4, // xyz = center_high, w = unused + @location(1) center_low: vec4, // xyz = center_low, w = minor_axis_ratio + @location(2) major_axis: vec4, // xyz = major_axis (semi-major vector), w = start_param + @location(3) normal: vec4, // xyz = normal, w = end_param + @location(4) color: vec4, // rgba + @location(5) params: vec4, // half_width_px, pattern_length, draw_depth, unused + @location(6) pat0: vec4, + @location(7) pat1: vec4, +} + +struct VertexOut { + @builtin(position) clip_pos: vec4, + @location(0) local_pos: vec2, + @location(1) semi_axes: vec2, + @location(2) hw_px: f32, + @location(3) color: vec4, + @location(4) pattern_length: f32, + @location(5) pat0: vec4, + @location(6) pat1: vec4, + @location(7) @interpolate(flat) min_elem: f32, + @location(8) @interpolate(flat) start_param: f32, + @location(9) @interpolate(flat) end_param: f32, +} + +fn resolve_hw(px_hw: f32) -> f32 { + var display_hw = max(px_hw * u.lineweight_scale, 0.5); + if u.lineweight_scale < 0.0 { + let scale = -u.lineweight_scale; + let base_hw = select( + min(px_hw * MODEL_LINEWEIGHT_BOOST, MODEL_LINEWEIGHT_MAX_PX * 0.5), + 0.5, + px_hw <= 0.5, + ); + display_hw = max(base_hw * scale, 0.5); + } + return select(0.5, display_hw, u.lwdisplay_enable > 0.5); +} + +@vertex fn vs_main(@builtin(vertex_index) vid: u32, in: InstanceIn) -> VertexOut { + let u_arr = array(-1.0, 1.0, 1.0, -1.0, 1.0, -1.0); + let v_arr = array(-1.0, -1.0, 1.0, -1.0, 1.0, 1.0); + let u_val = u_arr[vid]; + let v_val = v_arr[vid]; + + let center_high = in.center_high.xyz; + let center_low = in.center_low.xyz; + let major_vec = in.major_axis.xyz; + let norm_vec = in.normal.xyz; + + let a = length(major_vec); + let u_axis = select(vec3(1.0, 0.0, 0.0), major_vec / a, a > 1e-6); + let n_len = length(norm_vec); + let n_axis = select(vec3(0.0, 0.0, 1.0), norm_vec / n_len, n_len > 1e-6); + let v_axis = normalize(cross(n_axis, u_axis)); + + let ratio = clamp(in.center_low.w, 1e-6, 1.0); + let b = a * ratio; + + let color = in.color; + let hw_px = resolve_hw(in.params.x); + let pattern_length = in.params.y; + let draw_depth = in.params.z; + + let margin_world = (hw_px + 2.0) * max(u.world_per_pixel, 1e-6); + let ext_u = a + margin_world; + let ext_v = b + margin_world; + + let center_rel = (center_high - u.eye_high) + (center_low - u.eye_low); + let world_pos_rel = center_rel + (u_val * ext_u) * u_axis + (v_val * ext_v) * v_axis; + + var clip_pos = u.view_rot * vec4(world_pos_rel, 1.0); + clip_pos.z = clip_pos.z - draw_depth * DRAW_ORDER_BIAS * clip_pos.w; + + var out: VertexOut; + out.clip_pos = clip_pos; + out.local_pos = vec2(u_val * ext_u, v_val * ext_v); + out.semi_axes = vec2(a, b); + out.hw_px = hw_px; + out.color = color; + out.pattern_length = pattern_length; + out.pat0 = in.pat0; + out.pat1 = in.pat1; + out.start_param = in.major_axis.w; + out.end_param = in.normal.w; + + let elems = array(in.pat0.x, in.pat0.y, in.pat0.z, in.pat0.w, in.pat1.x, in.pat1.y, in.pat1.z, in.pat1.w); + var min_elem = 1e9; + for (var i = 0u; i < 8u; i++) { + let e = abs(elems[i]); + if e > 0.0 && e < min_elem { + min_elem = e; + } + } + out.min_elem = select(0.0, min_elem, min_elem < 1e8); + + return out; +} + +fn mod_tau(val: f32) -> f32 { + let m = val % TAU; + return select(m, m + TAU, m < 0.0); +} + +struct ClosestResult { + q: vec2, + t: f32, +} + +fn closest_point_ellipse(p_in: vec2, ab: vec2) -> ClosestResult { + let px = abs(p_in.x); + let py = abs(p_in.y); + let a = ab.x; + let b = ab.y; + if abs(a - b) < 1e-5 { + let len = length(p_in); + let q_circ = select(p_in, normalize(p_in) * a, len > 1e-6); + let ang = atan2(q_circ.y, q_circ.x); + let full_t = select(ang, ang + TAU, ang < 0.0); + return ClosestResult(q_circ, full_t); + } + + // Initial estimate from eccentric anomaly of radial projection + var t = atan2(py * a, px * b); + let k = a * a - b * b; + + // 3 iterations of Newton-Raphson for machine precision + for (var i = 0u; i < 3u; i++) { + let st = sin(t); + let ct = cos(t); + let f = k * st * ct - px * a * st + py * b * ct; + let f_prime = k * (ct * ct - st * st) - px * a * ct - py * b * st; + if abs(f_prime) > 1e-7 { + t -= f / f_prime; + } + } + t = clamp(t, 0.0, 1.5707963267948966); + + let q_quad = vec2(a * cos(t), b * sin(t)); + let q = vec2( + select(-q_quad.x, q_quad.x, p_in.x >= 0.0), + select(-q_quad.y, q_quad.y, p_in.y >= 0.0) + ); + + var full_t = t; + if (p_in.x < 0.0) { + full_t = 3.141592653589793 - t; + } + if (p_in.y < 0.0) { + full_t = select(TAU - t, 3.141592653589793 + t, p_in.x < 0.0); + } + + return ClosestResult(q, full_t); +} + +fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { + let elems = array(p0.x, p0.y, p0.z, p0.w, p1.x, p1.y, p1.z, p1.w); + var count = 0u; + for (var i = 0u; i < 8u; i++) { + if elems[i] != 0.0 { count = i + 1u; } + } + if count == 0u { + return true; + } + + let d = ((dist % pat_len) + pat_len) % pat_len; + var pos = 0.0f; + let dot_half = u.world_per_pixel * 0.75; + for (var i = 0u; i < count; i++) { + let elem = elems[i]; + if elem == 0.0 { + let dd = abs(d - pos); + if min(dd, pat_len - dd) <= dot_half { return true; } + } else if elem > 0.0 { + if d >= pos && d < pos + elem { return true; } + pos += elem; + } else { + pos += -elem; + } + } + return false; +} + +@fragment fn fs_main(in: VertexOut) -> @location(0) vec4 { + let ab = in.semi_axes; + let res = closest_point_ellipse(in.local_pos, ab); + let q = res.q; + let t = res.t; + + let sa = in.start_param; + let ea = in.end_param; + var sweep = mod_tau(ea - sa); + if sweep <= 1e-5 && abs(ea - sa) > 1e-5 { + sweep = TAU; + } + + let d_t = mod_tau(t - sa); + + var d_world: f32; + var arc_dist: f32; + + // Ramanujan approximation of ellipse perimeter for dash scaling + let h_val = ((ab.x - ab.y) * (ab.x - ab.y)) / max((ab.x + ab.y) * (ab.x + ab.y), 1e-6); + let perimeter = 3.141592653589793 * (ab.x + ab.y) * (1.0 + (3.0 * h_val) / max(10.0 + sqrt(max(4.0 - 3.0 * h_val, 0.0)), 1e-6)); + + if sweep >= TAU - 1e-5 || d_t <= sweep { + d_world = length(in.local_pos - q); + arc_dist = perimeter * (d_t / TAU); + } else { + let p_start = vec2(ab.x * cos(sa), ab.y * sin(sa)); + let p_end = vec2(ab.x * cos(ea), ab.y * sin(ea)); + let d_start = length(in.local_pos - p_start); + let d_end = length(in.local_pos - p_end); + if d_start < d_end { + d_world = d_start; + arc_dist = 0.0; + } else { + d_world = d_end; + arc_dist = perimeter * (sweep / TAU); + } + } + + // Isotropic quad screen-space filter width: + // Deriving from interpolated linear coordinates eliminates scanline derivative cancellations + let dx = dpdx(in.local_pos); + let dy = dpdy(in.local_pos); + let fw_len = sqrt(0.5 * (dot(dx, dx) + dot(dy, dy))); + let fw = max(select(u.world_per_pixel, fw_len, fw_len > 1e-6), 1e-6); + + let d_px = d_world / fw; + let alpha_cov = clamp(0.5 + in.hw_px - d_px, 0.0, 1.0); + if alpha_cov <= 0.0 { + discard; + } + + if in.pattern_length > 0.0 { + let pat_len = in.pattern_length * u.linetype_scale; + if in.min_elem * u.linetype_scale >= u.world_per_pixel && pat_len > 0.0 { + if !in_dash(arc_dist, pat_len, in.pat0, in.pat1) { + discard; + } + } + } + + let alpha = select(1.0, in.color.a, u.transparency_enable > 0.5) * alpha_cov; + return vec4(in.color.rgb, alpha); +} diff --git a/src/ui/color_select.rs b/src/ui/color_select.rs index 054d80812..bd05c1db7 100644 --- a/src/ui/color_select.rs +++ b/src/ui/color_select.rs @@ -5,6 +5,7 @@ use crate::app::Message; use crate::ui::properties::acad_color_display; +use crate::ui::ROW_H; use acadrust::types::Color as AcadColor; use iced::widget::{button, column, container, row, text}; use iced::{Background, Border, Color, Element, Length, Theme}; @@ -223,6 +224,7 @@ fn color_selector_with_indicator<'a>( ) .on_press(on_toggle) .padding([3, 6]) + .height(ROW_H) .width(Length::Fill); if !open { diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index b1f8cfae5..91ad9e195 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -165,16 +165,20 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) .to_absolute(Pixels(label_size)) .0; + // `Wrapping::Word` (not `WordOrGlyph`): a label that doesn't fit at this + // width wraps at the next word boundary instead of splitting mid-word. + // The width check catches the rare case Word-wrapping can't help — a + // single unbreakable "word" wider than the box — so that case correctly + // falls through to the wider stage below instead of reporting a false fit. let fits_one_line = |width: f32| { - ribbon_label_bounds( + let bounds = ribbon_label_bounds( renderer, label, width, - advanced_text::Wrapping::WordOrGlyph, + advanced_text::Wrapping::Word, label_size, - ) - .height - <= line_height + 0.5 + ); + bounds.width <= width + 0.5 && bounds.height <= line_height + 0.5 }; if fits_one_line(base_inner) { return LARGE_W; @@ -198,15 +202,14 @@ fn measure_large_width(renderer: &iced::Renderer, label: &str, label_size: f32) // same cap) that fits. let max_label_height = line_height * LARGE_LABEL_LINES + 0.5; let fits_two_lines = |width: f32| { - ribbon_label_bounds( + let bounds = ribbon_label_bounds( renderer, label, width, - advanced_text::Wrapping::WordOrGlyph, + advanced_text::Wrapping::Word, label_size, - ) - .height - <= max_label_height + ); + bounds.width <= width + 0.5 && bounds.height <= max_label_height }; let mut low = base_inner; let mut high = LARGE_LABEL_MAX_SINGLE_LINE_W; @@ -745,7 +748,7 @@ pub(super) fn render_large_dropdown<'a>( .size(ctx.label_font_size) .width(Fill) .align_x(iced::Center) - .wrapping(advanced_text::Wrapping::WordOrGlyph), + .wrapping(advanced_text::Wrapping::Word), ] .align_x(iced::Center) .spacing(0) @@ -878,7 +881,7 @@ pub(super) fn render_large<'a>( .size(ctx.label_font_size) .width(Fill) .align_x(iced::Center) - .wrapping(advanced_text::Wrapping::WordOrGlyph), + .wrapping(advanced_text::Wrapping::Word), ] .align_x(iced::Center) .spacing(0)