diff --git a/Cargo.lock b/Cargo.lock index 1c84449f7..b23557d69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4205,7 +4205,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/ramox81/cadcodec.git?rev=8b6eb9a#8b6eb9a4ca905902d63267cad9457f30092035a8" +source = "git+https://github.com/ramox81/cadcodec.git?rev=380e105#380e1057426ebd621f9967a06df97d54dce4c983" dependencies = [ "ahash 0.8.12", "anyhow", @@ -4228,7 +4228,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=597c8b8#597c8b817703491dd41bbbdb06e8c69d524ff2f8" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=2e40a7b#2e40a7b1ed45a94d9994789ed4a60d8bf32b7f97" dependencies = [ "opencadkernel", "serde_json", @@ -4237,7 +4237,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=52a9ebe#52a9ebeb9e7cd7815154ed261437908a88271753" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=7dc1e79#7dc1e79fdb1e1ff0f3bcdb054bbf5b70ac5ff5d0" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=52a9ebe#52a9ebeb9e7cd7815154ed261437908a88271753" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=7dc1e79#7dc1e79fdb1e1ff0f3bcdb054bbf5b70ac5ff5d0" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index e3fe7f86e..43f45e7f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,10 +33,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "cdf2277", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "52a9ebe", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "52a9ebe" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "597c8b8" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8464e0a", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "7dc1e79", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "7dc1e79" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "2e40a7b" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -164,4 +164,4 @@ name = "command_benchmarks" harness = false [patch."https://github.com/HakanSeven12/opencadcodec.git"] -opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "8b6eb9a" } +opencadcodec = { git = "https://github.com/ramox81/cadcodec.git", rev = "380e105" } diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index f3f076d11..ffae1f7a2 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -393,6 +393,7 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { let n_queries = if runner.quick_mode { 200 } else { 1_000 }; let mut click_samples = Vec::with_capacity(5); + let draw_depth = rustc_hash::FxHashMap::default(); for _ in 0..5 { let t0 = Instant::now(); for q in 0..n_queries { @@ -406,6 +407,7 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { bounds, false, 8.0, + &draw_depth, ); black_box(hit); } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 79a005a31..ffd66d723 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "cdf2277", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8464e0a", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "cdf2277", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8464e0a", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index b7b71c0b1..4fadf975c 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] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "cdf2277", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8464e0a", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/docs/automation/README.md b/docs/automation/README.md index 8c700d7f1..5a50320c5 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -301,10 +301,58 @@ maps onto these three steps rather than a native op. `query` entities of type `TEXT`/`MTEXT` return the raw stored string in `value` plus a formatting-free rendering in `text` (MTEXT inline codes such as `\A1;` or `\P` are resolved; `%%d`-style TEXT codes become their glyphs). With `detail:"full"`, degenerate-width text bounds are widened with a documented estimate (height × 0.8 × character count) so `bounds`-based region filters stay usable. +### Text operations — search, audit and replace + +Three dedicated text operations go beyond `query` for bulk text work: + +**`text_search`** (read) scans all `Text`, `MText`, and `Insert` attribute values for a pattern. It supports case-insensitive, whole-word, and accent-insensitive matching, scoped to a layer, entity type list, or handle set. + +```json +{"op":"text_search","find":"CUVE","match_case":false,"whole_word":true,"ignore_accents":true} +``` + +**`text_audit`** (read) is a non-mutating spell-check and dry-run replacement preview. It accepts three independent analysis modes, all optional, in one call: + +| Parameter | Effect | +|---|---| +| `pairs` | `[{"find":"X","replace":"Y"}, …]` — simulates a batch replacement and reports what would change, without touching the document | +| `check_terms` | `["CIRTUITS","NON TRAITE"]` — flags entities that contain suspect strings | +| `dictionary` | `["EAU","POMPE","VANNE"]` — any word ≥ 3 letters not found in this list is reported as unrecognised | +| `system_spellcheck` | `true` to use the OS native spell-checker alongside or instead of the agent dictionary | +| `language` | BCP-47 tag for the system speller, e.g. `"en-US"` or `"fr-FR"` (defaults to the OS default) | +| `suggest` | `true` (default) to include spelling suggestions for unrecognised words | + +The agent dictionary and system spell-checker work as a layered pipeline: a word that matches the agent dictionary is accepted immediately; only words that fail (or when no dictionary is supplied) are forwarded to the OS spell-checker when enabled. + +System spell-check backends: + +| Platform | Backend | Notes | +|---|---|---| +| Windows 8.1+ | Win32 `ISpellChecker` COM API | Per-language; checks available via Settings → Language. ALL-CAPS words are checked in lowercase since Windows skips all-caps by default. | +| macOS | `NSSpellChecker` | Uses the system language or the `language` parameter | +| Linux | Hunspell `.dic` files or `hunspell` CLI | Reads `/usr/share/hunspell/` dictionaries; falls back to pipe mode | + +Example combining an agent dictionary with system spell-check: + +```json +{ + "op": "text_audit", + "dictionary": ["VANNE", "CHAUDIÈRE", "POMPE"], + "system_spellcheck": true, + "language": "fr-FR", + "suggest": true +} +``` + +The response includes a `system_speller` object with `enabled`, `available`, `backend`, and `language` fields, plus an `unrecognized_words` array with each word's `handle`, `position`, and optional `suggestions`. + +**`text_replace`** (execute) performs the actual batch replacement with single-transaction undo. Add `"dry_run": true` to preview the result without mutating the document or pushing an undo entry. + ### Smoke test The stdio path is covered black-box by a Python script that draws two entities, exports them with `wblock`, plots them with `plot`, and checks the capability advertisement: + ```sh python3 docs/automation/serve_smoke.py target/debug/OpenCADStudio ``` diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index dd1585f49..bbbff2d31 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -52,16 +52,18 @@ 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"]) == 38 + assert len(execute_request["oneOf"]) == 39 op_enum = execute_request["properties"]["op"]["enum"] for shipped in ("entities_create", "entities_delete", "entities_transform", "block_define", "block_delete", "file_identity", "xdata_set", "view_focus", "wblock", "plot", "entities_copy_to", "group_create", "selection_set_save", "selection_set_load", "close", "sysvar", "layout_create", - "page_setup_set"): + "page_setup_set", "text_replace"): assert shipped in op_enum, shipped assert "xdata_get" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + assert "text_search" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + assert "text_audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] 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"] diff --git a/docs/automation/native.md b/docs/automation/native.md index d86cb6b99..ddfbf978f 100644 --- a/docs/automation/native.md +++ b/docs/automation/native.md @@ -33,7 +33,7 @@ Two request styles share the channel, told apart by the `protocol` field: | Style | Shape | Operations | |---|---|---| -| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `audit`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` | +| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`, `text_replace`; reads `state`, `capabilities`, `audit`, `text_audit`, `text_search`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` | | legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `audit`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` | The two styles differ in how a waiting command is treated. A protocol-1 `run` diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 8bfcaff55..44c3bdfdf 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=cdf2277#cdf227782acab8b0b7035d99ed02530a90ae6773" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=8464e0a#8464e0a63439dcb724aae446093979103cd8bfbd" dependencies = [ "ahash 0.8.12", "anyhow", diff --git a/src/app/automation.rs b/src/app/automation.rs index d822e9465..ed5bdaa58 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -679,6 +679,9 @@ impl OpenCADStudio { "entities" => self.entity_summary(), "audit" => self.document_audit(&req), "query" => self.entity_query(&req), + "text_search" => self.automation_text_search(&req), + "text_audit" => self.automation_text_audit(&req), + "text_replace" => self.automation_text_replace(&req), "records" => self.record_query(&req), "record_schema" => self.record_schema(&req), "capabilities" => self.record_capabilities(), diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index a23c1e810..ba5fee7f4 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -391,6 +391,7 @@ impl OpenCADStudio { }); match seed { Some((s, tangent)) => { + self.last_point = Some(s); let new_cmd = ArcContCommand::new(s, tangent); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index cd08c9916..f2af51592 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -471,6 +471,8 @@ impl OpenCADStudio { | "xdata_get" | "get_selection" | "audit" + | "text_search" + | "text_audit" ); if !query && !self.control.enabled { return ( @@ -729,6 +731,8 @@ impl OpenCADStudio { } "xdata_get" => self.xdata_read(&req), "get_selection" => self.control_get_selection(), + "text_search" => self.control_text_search(&req).unwrap_or_else(|e| e), + "text_audit" => self.control_text_audit(&req).unwrap_or_else(|e| e), "history" => { json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::>()}) } @@ -975,6 +979,7 @@ impl OpenCADStudio { "entities_delete" => self.control_entities_delete(req)?, "entities_transform" => self.control_entities_transform(req)?, "entities_copy_to" => self.control_entities_copy_to(req)?, + "text_replace" => self.control_text_replace(req)?, "xdata_set" => self.control_xdata_set(req)?, "block_define" => self.control_block_define(req)?, "block_delete" => self.control_block_delete(req)?, @@ -1340,6 +1345,8 @@ pub(crate) mod http_bridge; mod interactive; mod sheets; pub(crate) use sheets::new_guid_v4; +pub(crate) mod text_ops; +pub(crate) mod spellcheck; #[cfg(test)] mod p1_tests; diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 705e5e639..331142ce6 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -566,3 +566,286 @@ fn state_reports_the_hand_seed() { let after = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap(); assert!(after > before, "issuing a handle advances the seed"); } + +#[test] +fn text_search_op_queries_text_and_attributes() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"ts_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"}, + {"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;POMPE A EAU}","layer":"0"}, + {"type":"Text","position":[20,0,0],"value":"BAROMETRE","layer":"0"}, + {"type":"Text","position":[30,0,0],"value":"10 BAR","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Case-insensitive search finds CIRTUITS + let res = app.automation_op(r#"{"op":"text_search","find":"cirtuits"}"#); + assert_eq!(res["ok"], true); + assert_eq!(res["count"], 1); + assert_eq!(res["matches"][0]["match_text"], "cirtuits"); + assert_eq!(res["matches"][0]["plain_text"], "CIRTUITS 28-09-2026"); + + // Case-sensitive search with wrong case finds nothing + let res_cs = app.automation_op(r#"{"op":"text_search","find":"cirtuits","match_case":true}"#); + assert_eq!(res_cs["ok"], true); + assert_eq!(res_cs["count"], 0); + + // Whole-word search: "bar" matches "10 BAR" but not "BAROMETRE" + let res_ww = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":true}"#); + assert_eq!(res_ww["ok"], true); + assert_eq!(res_ww["count"], 1); + assert_eq!(res_ww["matches"][0]["plain_text"], "10 BAR"); + + // Non-whole-word search finds both "BAROMETRE" and "10 BAR" + let res_all = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":false}"#); + assert_eq!(res_all["ok"], true); + assert_eq!(res_all["count"], 2); +} + +#[test] +fn text_replace_op_batch_replaces_preserves_formatting_and_undoes() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"tr_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"VANNE A ARRET","layer":"0"}, + {"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic|b0|i0;V15-CUVE EAU NON TRAITE}","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Execute batch replace with 2 pairs in one atomic step + let rep = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_r1","document_id":{doc},"pairs":[ + {"find":"NON TRAITE","replace":"NON TRAITÉE"}, + {"find":"VANNE A ARRET","replace":"VANNE D'ARRÊT"} + ]}"#, + ); + assert_eq!(rep["ok"], true, "{}", rep["error"]); + assert_eq!(rep["result"]["replaced"], 2); + assert_eq!(rep["result"]["entities_changed"], 2); + + // Verify MText formatting codes were preserved + let q = app.automation_op(r#"{"op":"text_search","find":"NON TRAITÉE"}"#); + assert_eq!(q["ok"], true); + assert_eq!(q["count"], 1); + let raw = q["matches"][0]["raw_value"].as_str().unwrap(); + assert!(raw.contains("Century Gothic"), "raw MText should keep font format code: {raw}"); + assert!(raw.contains("NON TRAITÉE"), "raw MText should contain replaced text: {raw}"); + + // Verify Text was replaced + let q2 = app.automation_op(r#"{"op":"text_search","find":"VANNE D'ARRÊT"}"#); + assert_eq!(q2["ok"], true); + assert_eq!(q2["count"], 1); + + // Undo restores the original text in one step + app.automation_op(r#"{"op":"undo"}"#); + let q_orig = app.automation_op(r#"{"op":"text_search","find":"NON TRAITE"}"#); + assert_eq!(q_orig["ok"], true); + assert_eq!(q_orig["count"], 1); + + let q_orig2 = app.automation_op(r#"{"op":"text_search","find":"VANNE A ARRET"}"#); + assert_eq!(q_orig2["ok"], true); + assert_eq!(q_orig2["count"], 1); +} + +#[test] +fn text_audit_op_simulates_dry_run_checks_terms_and_dictionary() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"ta_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"}, + {"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;CUVE EAU NON TRAITE}","layer":"0"}, + {"type":"Text","position":[20,0,0],"value":"POMPE A EAU","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Test text_audit with pairs, check_terms, and dictionary + let audit_res = app.automation_op( + r#"{ + "op":"text_audit", + "pairs":[ + {"find":"CIRTUITS","replace":"CIRCUITS"}, + {"find":"NON TRAITE","replace":"NON TRAITÉE"}, + {"find":"INEXISTANT_PAIR","replace":"REPLACEMENT"} + ], + "check_terms":["CIRTUITS", "NON TRAITE", "INEXISTANT_TERM"], + "dictionary":["EAU", "POMPE", "28-09-2026"] + }"#, + ); + assert_eq!(audit_res["ok"], true, "{}", audit_res["error"]); + + // Check summary + assert_eq!(audit_res["summary"]["pairs_total"], 3); + assert_eq!(audit_res["summary"]["pairs_unmatched"], 1); + assert_eq!(audit_res["summary"]["check_terms_total"], 3); + assert_eq!(audit_res["summary"]["check_terms_found"], 2); + + // Unmatched reporting + assert_eq!(audit_res["unmatched_pairs"], json!(["INEXISTANT_PAIR"])); + assert_eq!(audit_res["unmatched_check_terms"], json!(["INEXISTANT_TERM"])); + + // Simulated changes has 2 entries (Text and MText) + let changes = audit_res["simulated_changes"].as_array().unwrap(); + assert_eq!(changes.len(), 2); + assert_eq!(changes[0]["find"], "CIRTUITS"); + assert_eq!(changes[0]["replace"], "CIRCUITS"); + assert_eq!(changes[0]["replaced"], 1); + assert!(changes[1]["after"].as_str().unwrap().contains("NON TRAITÉE")); + + // Suspect matches + let suspects = audit_res["suspect_matches"].as_array().unwrap(); + assert_eq!(suspects.len(), 2); + + // Verify document was NOT mutated (pure read op) + let q = app.automation_op(r#"{"op":"text_search","find":"CIRTUITS"}"#); + assert_eq!(q["ok"], true); + assert_eq!(q["count"], 1); +} + +#[test] +fn text_replace_op_supports_dry_run_without_mutating_or_undo() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"tr_dry1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"TEST BEFORE REPLACE","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Run text_replace with dry_run = true + let dry_res = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_sim1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":true}"#, + ); + assert_eq!(dry_res["ok"], true, "{}", dry_res["error"]); + assert_eq!(dry_res["result"]["dry_run"], true); + assert_eq!(dry_res["result"]["replaced"], 1); + assert_eq!(dry_res["result"]["entities_changed"], 1); + + // Verify the document was NOT modified + let q_before = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#); + assert_eq!(q_before["ok"], true); + assert_eq!(q_before["count"], 1); + + let q_after = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#); + assert_eq!(q_after["ok"], true); + assert_eq!(q_after["count"], 0); + + // Now run with dry_run = false + let real_res = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_real1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":false}"#, + ); + assert_eq!(real_res["ok"], true, "{}", real_res["error"]); + assert_eq!(real_res["result"]["replaced"], 1); + + // Now it was modified + let q_after2 = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#); + assert_eq!(q_after2["ok"], true); + assert_eq!(q_after2["count"], 1); + + // Undo restores it + app.automation_op(r#"{"op":"undo"}"#); + let q_undone = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#); + assert_eq!(q_undone["ok"], true); + assert_eq!(q_undone["count"], 1); +} + +#[test] +fn text_audit_system_spellcheck_flags_misspelled_words() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"sp_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"The cirtuit breaker is conected","layer":"0"}, + {"type":"Text","position":[10,0,0],"value":"correct english text here","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Run text_audit with system_spellcheck enabled, English language, + // and an agent dictionary that whitelists "breaker" + let audit = app.automation_op( + r#"{ + "op":"text_audit", + "system_spellcheck": true, + "language": "en-US", + "suggest": true, + "dictionary": ["breaker", "the"] + }"#, + ); + assert_eq!(audit["ok"], true, "{}", audit["error"]); + + // system_speller metadata should be present regardless of availability + let sp = &audit["system_speller"]; + assert_eq!(sp["enabled"], true); + + if sp["available"].as_bool() == Some(true) { + // Backend should be reported (e.g. "windows" on Windows) + assert!(sp["backend"].as_str().unwrap().len() > 0); + assert!(sp["language"].as_str().is_some()); + + // "cirtuit" and "conected" should be flagged as unrecognized + let words = audit["unrecognized_words"].as_array().unwrap(); + let flagged: Vec<&str> = words.iter().filter_map(|w| w["word"].as_str()).collect(); + assert!( + flagged.iter().any(|w| w.eq_ignore_ascii_case("cirtuit")), + "expected 'cirtuit' to be flagged, got: {:?}", + flagged + ); + assert!( + flagged.iter().any(|w| w.eq_ignore_ascii_case("conected")), + "expected 'conected' to be flagged, got: {:?}", + flagged + ); + + // "breaker" should NOT be flagged (it's in the agent dictionary) + assert!( + !flagged.iter().any(|w| w.eq_ignore_ascii_case("breaker")), + "expected 'breaker' to be whitelisted by agent dict, got: {:?}", + flagged + ); + + // At least one flagged word should have suggestions — except from a + // bare Linux wordlist, which can tell a word is unknown but has no + // way to suggest one without the hunspell CLI. + let has_suggestions = words.iter().any(|w| { + w["suggestions"] + .as_array() + .map(|a| !a.is_empty()) + .unwrap_or(false) + }); + assert!( + has_suggestions || sp["backend"] == "linux_system", + "expected at least one word to have suggestions" + ); + + // Summary should reflect unrecognized words + assert!( + audit["summary"]["unrecognized_words_count"].as_u64().unwrap() >= 2, + "expected at least 2 unrecognized words in summary" + ); + } else { + // System speller not available (e.g. CI without spell-check service) + // Just verify the response structure is well-formed + assert!(audit["unrecognized_words"].is_array()); + eprintln!( + "System spellcheck not available (backend: {}), skipping content assertions", + sp["backend"] + ); + } +} + diff --git a/src/app/control/spellcheck.rs b/src/app/control/spellcheck.rs new file mode 100644 index 000000000..704a961be --- /dev/null +++ b/src/app/control/spellcheck.rs @@ -0,0 +1,770 @@ +//! Native OS spell-checker abstraction supporting Windows, macOS, and Linux. +//! +//! - **Windows**: Windows 8+ native Spell Checking API (`ISpellChecker` via COM). +//! - **macOS**: Apple AppKit `NSSpellChecker` via Objective-C. +//! - **Linux**: Standard system dictionary wordlists (`/usr/share/hunspell`, `/usr/share/myspell`, +//! `/usr/share/dict`) and Hunspell / Aspell pipe integration. +//! - **Other**: Graceful no-op fallback. + +/// Unified system spell checker handle. +pub struct SystemSpeller { + backend: Backend, + language: String, +} + +enum Backend { + #[cfg(target_os = "windows")] + Windows(windows_impl::WindowsSpeller), + #[cfg(target_os = "macos")] + Mac(macos_impl::MacSpeller), + #[cfg(target_os = "linux")] + Linux(linux_impl::LinuxSpeller), + Fallback(fallback_impl::FallbackSpeller), +} + +impl SystemSpeller { + /// Create a system speller for the given BCP-47 language tag (e.g. "fr-FR", "en-US", "de-DE", "es-ES"), + /// or default to the host system's UI locale. + pub fn new(requested_lang: Option<&str>) -> Self { + let lang = normalize_language_tag(requested_lang); + + #[cfg(target_os = "windows")] + { + if let Some(win_speller) = windows_impl::WindowsSpeller::new(&lang) { + let actual_lang = win_speller.language().to_string(); + return Self { + backend: Backend::Windows(win_speller), + language: actual_lang, + }; + } + } + + #[cfg(target_os = "macos")] + { + if let Some(mac_speller) = macos_impl::MacSpeller::new(&lang) { + let actual_lang = mac_speller.language().to_string(); + return Self { + backend: Backend::Mac(mac_speller), + language: actual_lang, + }; + } + } + + #[cfg(target_os = "linux")] + { + if let Some(linux_speller) = linux_impl::LinuxSpeller::new(&lang) { + let actual_lang = linux_speller.language().to_string(); + return Self { + backend: Backend::Linux(linux_speller), + language: actual_lang, + }; + } + } + + Self { + backend: Backend::Fallback(fallback_impl::FallbackSpeller), + language: lang, + } + } + + /// Whether a native system spell-checker is active and available. + pub fn is_available(&self) -> bool { + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(_) => true, + #[cfg(target_os = "macos")] + Backend::Mac(_) => true, + #[cfg(target_os = "linux")] + Backend::Linux(l) => l.is_available(), + Backend::Fallback(_) => false, + } + } + + /// Returns the active backend name ("windows", "macos", "linux_system", or "none"). + pub fn backend_name(&self) -> &'static str { + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(_) => "windows", + #[cfg(target_os = "macos")] + Backend::Mac(_) => "macos", + #[cfg(target_os = "linux")] + Backend::Linux(_) => "linux_system", + Backend::Fallback(_) => "none", + } + } + + /// The active language tag (e.g. "fr-FR", "en-US"). + pub fn language(&self) -> &str { + &self.language + } + + /// Check if a word is spelled correctly according to the system spell-checker. + /// + /// Returns `(is_correct, suggestions)`. + /// When `is_correct` is false, `suggestions` contains system-suggested corrections. + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if word.trim().is_empty() { + return (true, Vec::new()); + } + + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(w) => w.check_word(word, want_suggestions), + #[cfg(target_os = "macos")] + Backend::Mac(m) => m.check_word(word, want_suggestions), + #[cfg(target_os = "linux")] + Backend::Linux(l) => l.check_word(word, want_suggestions), + Backend::Fallback(_) => (true, Vec::new()), + } + } +} + +fn normalize_language_tag(input: Option<&str>) -> String { + let raw: String = match input { + Some(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => { + #[cfg(target_os = "windows")] + { + let mut buf = [0u16; 85]; + let n = unsafe { windows_impl::GetUserDefaultLocaleName(buf.as_mut_ptr(), 85) }; + if n > 1 { + String::from_utf16_lossy(&buf[..(n - 1) as usize]) + } else { + "en-US".to_string() + } + } + #[cfg(not(target_os = "windows"))] + { + if let Ok(lang) = std::env::var("LANG").or_else(|_| std::env::var("LC_ALL")) { + let cleaned = lang.split('.').next().unwrap_or("").replace('_', "-"); + if !cleaned.is_empty() { + cleaned + } else { + "en-US".to_string() + } + } else { + "en-US".to_string() + } + } + } + }; + + let lower = raw.to_lowercase(); + match lower.as_str() { + "fr" | "french" | "francais" | "français" => "fr-FR".to_string(), + "en" | "english" => "en-US".to_string(), + "de" | "german" | "deutsch" => "de-DE".to_string(), + "es" | "spanish" | "espanol" | "español" => "es-ES".to_string(), + "it" | "italian" | "italiano" => "it-IT".to_string(), + _ => raw.to_string(), + } +} + +// ============================================================================ +// Windows Backend: Win32 Spell Checking API (ISpellChecker via COM) +// ============================================================================ +#[cfg(target_os = "windows")] +pub(crate) mod windows_impl { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + + type HRESULT = i32; + type BOOL = i32; + + #[repr(C)] + struct GUID { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], + } + + const CLSID_SPELL_CHECKER_FACTORY: GUID = GUID { + data1: 0x7ab36653, + data2: 0x1796, + data3: 0x484b, + data4: [0xbd, 0xfa, 0xe7, 0x4f, 0x1d, 0xb7, 0xc1, 0xdc], + }; + + const IID_ISPELL_CHECKER_FACTORY: GUID = GUID { + data1: 0x8e018a9d, + data2: 0x2415, + data3: 0x4677, + data4: [0xbf, 0x08, 0x79, 0x4e, 0xa6, 0x1f, 0x94, 0xbb], + }; + + #[repr(C)] + struct ISpellCheckerFactoryVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_supported_languages: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + is_supported: unsafe extern "system" fn(*mut c_void, *const u16, *mut BOOL) -> HRESULT, + create_spell_checker: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + } + + #[repr(C)] + struct ISpellCheckerVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_language_tag: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + check: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + suggest: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + add: unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT, + ignore: unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT, + auto_correct: unsafe extern "system" fn(*mut c_void, *const u16, *const u16) -> HRESULT, + get_option_value: unsafe extern "system" fn(*mut c_void, *const u16, *mut u8) -> HRESULT, + get_option_ids: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + get_id: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + get_localized_name: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + } + + #[repr(C)] + struct IEnumSpellingErrorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + next: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + } + + #[repr(C)] + struct ISpellingErrorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_start_index: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_length: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_corrective_action: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_replacement: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + } + + #[repr(C)] + struct IEnumStringVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + next: unsafe extern "system" fn(*mut c_void, u32, *mut *mut u16, *mut u32) -> HRESULT, + } + + #[link(name = "ole32")] + extern "system" { + fn CoInitializeEx(pv_reserved: *const c_void, dw_co_init: u32) -> HRESULT; + fn CoCreateInstance( + rclsid: *const GUID, + p_unk_outer: *mut c_void, + dw_cls_context: u32, + riid: *const GUID, + ppv: *mut *mut c_void, + ) -> HRESULT; + fn CoTaskMemFree(pv: *mut c_void); + } + + #[link(name = "kernel32")] + extern "system" { + pub fn GetUserDefaultLocaleName(lp_locale_name: *mut u16, cch_locale_name: i32) -> i32; + } + + fn to_wide(s: &str) -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() + } + + unsafe fn from_wide_ptr(ptr: *mut u16) -> String { + if ptr.is_null() { + return String::new(); + } + let mut len = 0; + while *ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(ptr, len); + let s = String::from_utf16_lossy(slice); + CoTaskMemFree(ptr as *mut c_void); + s + } + + pub struct WindowsSpeller { + checker_obj: *mut c_void, + language: String, + } + + // Windows COM objects created with CLSCTX_INPROC_SERVER are safe to invoke from our control thread. + unsafe impl Send for WindowsSpeller {} + unsafe impl Sync for WindowsSpeller {} + + impl WindowsSpeller { + pub fn new(lang_tag: &str) -> Option { + unsafe { + CoInitializeEx(std::ptr::null(), 0); + + let mut factory_obj: *mut c_void = std::ptr::null_mut(); + let hr = CoCreateInstance( + &CLSID_SPELL_CHECKER_FACTORY, + std::ptr::null_mut(), + 1, // CLSCTX_INPROC_SERVER + &IID_ISPELL_CHECKER_FACTORY, + &mut factory_obj, + ); + if hr != 0 || factory_obj.is_null() { + return None; + } + + let factory_vtbl = *(factory_obj as *const *const ISpellCheckerFactoryVtbl); + + // Try requested language, then fallback languages + let mut candidates = vec![lang_tag.to_string()]; + if !candidates.contains(&"en-US".to_string()) { + candidates.push("en-US".to_string()); + } + + let mut chosen_checker = None; + let mut chosen_lang = String::new(); + + for candidate in candidates { + let wide_cand = to_wide(&candidate); + let mut supported: BOOL = 0; + let hr_supp = ((*factory_vtbl).is_supported)( + factory_obj, + wide_cand.as_ptr(), + &mut supported, + ); + if hr_supp == 0 && supported != 0 { + let mut checker_obj: *mut c_void = std::ptr::null_mut(); + let hr_create = ((*factory_vtbl).create_spell_checker)( + factory_obj, + wide_cand.as_ptr(), + &mut checker_obj, + ); + if hr_create == 0 && !checker_obj.is_null() { + chosen_checker = Some(checker_obj); + chosen_lang = candidate; + break; + } + } + } + + ((*factory_vtbl).release)(factory_obj); + + chosen_checker.map(|obj| Self { + checker_obj: obj, + language: chosen_lang, + }) + } + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if self.checker_obj.is_null() { + return (true, Vec::new()); + } + + unsafe { + let checker_vtbl = *(self.checker_obj as *const *const ISpellCheckerVtbl); + let wide_text = to_wide(word); + + let has_err_exact = has_spelling_error(checker_vtbl, self.checker_obj, &wide_text); + + let is_all_caps = word.chars().any(char::is_alphabetic) + && word == word.to_uppercase(); + + let mut is_misspelled = has_err_exact; + + // Windows spellcheck by default ignores all-caps words (to avoid flagging acronyms). + // In CAD drawings, virtually all text is uppercase (e.g. CIRTUITS, VANNE). + // If it wasn't flagged in all-caps, test its lowercase form to check if it's a real word. + if !is_misspelled && is_all_caps { + let lower = word.to_lowercase(); + let wide_lower = to_wide(&lower); + if has_spelling_error(checker_vtbl, self.checker_obj, &wide_lower) { + is_misspelled = true; + } + } + + let mut suggestions = Vec::new(); + if is_misspelled && want_suggestions { + // Query suggestions for word and/or its lowercase counterpart + let mut enum_str: *mut c_void = std::ptr::null_mut(); + let target_wide = if is_all_caps { + to_wide(&word.to_lowercase()) + } else { + wide_text.clone() + }; + + if ((*checker_vtbl).suggest)(self.checker_obj, target_wide.as_ptr(), &mut enum_str) == 0 + && !enum_str.is_null() + { + let enum_vtbl = *(enum_str as *const *const IEnumStringVtbl); + loop { + let mut str_ptr: *mut u16 = std::ptr::null_mut(); + let mut fetched: u32 = 0; + if ((*enum_vtbl).next)(enum_str, 1, &mut str_ptr, &mut fetched) != 0 + || fetched == 0 + { + break; + } + let s = from_wide_ptr(str_ptr); + if !s.is_empty() { + // Match uppercase if original word was uppercase + let final_s = if is_all_caps { s.to_uppercase() } else { s }; + if !suggestions.contains(&final_s) { + suggestions.push(final_s); + } + } + if suggestions.len() >= 5 { + break; + } + } + ((*enum_vtbl).release)(enum_str); + } + } + + (!is_misspelled, suggestions) + } + } + } + + unsafe fn has_spelling_error( + checker_vtbl: *const ISpellCheckerVtbl, + checker_obj: *mut c_void, + wide_text: &[u16], + ) -> bool { + let mut errors_obj: *mut c_void = std::ptr::null_mut(); + let hr_check = ((*checker_vtbl).check)(checker_obj, wide_text.as_ptr(), &mut errors_obj); + let mut has_error = false; + if hr_check == 0 && !errors_obj.is_null() { + let enum_vtbl = *(errors_obj as *const *const IEnumSpellingErrorVtbl); + let mut err_item: *mut c_void = std::ptr::null_mut(); + if ((*enum_vtbl).next)(errors_obj, &mut err_item) == 0 && !err_item.is_null() { + has_error = true; + ((*(*(err_item as *const *const ISpellingErrorVtbl))).release)(err_item); + } + ((*enum_vtbl).release)(errors_obj); + } + has_error + } + + impl Drop for WindowsSpeller { + fn drop(&mut self) { + if !self.checker_obj.is_null() { + unsafe { + let vtbl = *(self.checker_obj as *const *const ISpellCheckerVtbl); + ((*vtbl).release)(self.checker_obj); + } + } + } + } +} + +// ============================================================================ +// macOS Backend: NSSpellChecker via AppKit +// ============================================================================ +#[cfg(target_os = "macos")] +pub(crate) mod macos_impl { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + use objc2_foundation::NSString; + + pub struct MacSpeller { + checker: *mut AnyObject, + language: String, + } + + unsafe impl Send for MacSpeller {} + unsafe impl Sync for MacSpeller {} + + impl MacSpeller { + pub fn new(lang_tag: &str) -> Option { + unsafe { + let cls = AnyClass::get("NSSpellChecker")?; + let checker: *mut AnyObject = msg_send![cls, sharedSpellChecker]; + if checker.is_null() { + return None; + } + + let ns_lang = NSString::from_str(lang_tag); + let ok: bool = msg_send![checker, setLanguage: &*ns_lang]; + let actual_lang = if ok { + lang_tag.to_string() + } else { + let cur_lang: *mut AnyObject = msg_send![checker, language]; + if !cur_lang.is_null() { + let cur: &NSString = &*(cur_lang as *const NSString); + cur.to_string() + } else { + lang_tag.to_string() + } + }; + + Some(Self { + checker, + language: actual_lang, + }) + } + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if self.checker.is_null() || word.is_empty() { + return (true, Vec::new()); + } + + unsafe { + let is_all_caps = word.chars().any(char::is_alphabetic) + && word == word.to_uppercase(); + + let ns_word = NSString::from_str(word); + let range: objc2_foundation::NSRange = msg_send![ + self.checker, + checkSpellingOfString: &*ns_word, + startingAt: 0isize, + language: std::ptr::null::(), + wrap: false, + inSpellDocumentWithTag: 0isize, + wordCount: std::ptr::null_mut::() + ]; + + let mut is_misspelled = range.length > 0; + + // Also check lowercase if all-caps + if !is_misspelled && is_all_caps { + let lower = word.to_lowercase(); + let ns_lower = NSString::from_str(&lower); + let range_lower: objc2_foundation::NSRange = msg_send![ + self.checker, + checkSpellingOfString: &*ns_lower, + startingAt: 0isize, + language: std::ptr::null::(), + wrap: false, + inSpellDocumentWithTag: 0isize, + wordCount: std::ptr::null_mut::() + ]; + if range_lower.length > 0 { + is_misspelled = true; + } + } + + let mut suggestions = Vec::new(); + if is_misspelled && want_suggestions { + let target_str = if is_all_caps { word.to_lowercase() } else { word.to_string() }; + let ns_target = NSString::from_str(&target_str); + let word_len = target_str.encode_utf16().count(); + let r = objc2_foundation::NSRange { + location: 0, + length: word_len, + }; + let arr: *mut AnyObject = msg_send![ + self.checker, + guessesForWordRange: r, + inString: &*ns_target, + language: std::ptr::null::(), + inSpellDocumentWithTag: 0isize + ]; + if !arr.is_null() { + let count: usize = msg_send![arr, count]; + for i in 0..count.min(5) { + let item: *mut AnyObject = msg_send![arr, objectAtIndex: i]; + if !item.is_null() { + let ns_item: &NSString = &*(item as *const NSString); + let mut s = ns_item.to_string(); + if is_all_caps { + s = s.to_uppercase(); + } + suggestions.push(s); + } + } + } + } + + (!is_misspelled, suggestions) + } + } + } +} + +// ============================================================================ +// Linux Backend: System Dictionaries & Hunspell / Aspell +// ============================================================================ +#[cfg(target_os = "linux")] +pub(crate) mod linux_impl { + use std::collections::HashSet; + use std::fs::File; + use std::io::{BufRead, BufReader}; + use std::path::Path; + + const LINUX_DICT_DIRS: &[&str] = &[ + "/usr/share/hunspell", + "/usr/share/myspell", + "/usr/share/myspell/dicts", + "/var/lib/dictionaries-common/hunspell_dic", + "/usr/share/dict", + ]; + + pub struct LinuxSpeller { + words: HashSet, + language: String, + has_cli_tool: bool, + } + + impl LinuxSpeller { + pub fn new(lang_tag: &str) -> Option { + let mut words = HashSet::new(); + let mut found_any = false; + + // Search system directories for .dic or wordlist files matching the requested language + let short_lang = lang_tag.split('-').next().unwrap_or(lang_tag); + let candidates = [ + format!("{}.dic", lang_tag.replace('-', "_")), + format!("{}.dic", lang_tag), + format!("{}.dic", short_lang), + "words".to_string(), + short_lang.to_string(), + ]; + + for dir in LINUX_DICT_DIRS { + let base = Path::new(dir); + if !base.is_dir() { + continue; + } + for candidate in &candidates { + let path = base.join(candidate); + if path.is_file() { + if load_wordlist(&path, &mut words) { + found_any = true; + break; + } + } + } + if found_any { + break; + } + } + + // Only hunspell is ever invoked (`check_via_cli`), so only it counts. + let has_cli = is_command_in_path("hunspell"); + + if !found_any && !has_cli { + return None; + } + + Some(Self { + words, + language: lang_tag.to_string(), + has_cli_tool: has_cli, + }) + } + + pub fn is_available(&self) -> bool { + !self.words.is_empty() || self.has_cli_tool + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + let clean = word.trim().to_lowercase(); + if clean.is_empty() { + return (true, Vec::new()); + } + + if self.words.contains(&clean) { + return (true, Vec::new()); + } + + // If CLI hunspell is available, fallback to pipe check + if self.has_cli_tool { + if let Some((valid, suggs)) = check_via_cli(word, &self.language, want_suggestions) { + return (valid, suggs); + } + } + + // Not in the wordlist and no CLI answer: misspelled when there is + // a wordlist to judge by, unjudged (accepted) when there is none. + (self.words.is_empty(), Vec::new()) + } + } + + fn load_wordlist(path: &Path, words: &mut HashSet) -> bool { + let Ok(file) = File::open(path) else { return false }; + let reader = BufReader::new(file); + for line in reader.lines().flatten() { + let entry = line.trim(); + if entry.is_empty() || entry.starts_with('#') { + continue; + } + // Strip Hunspell flags: "word/flags" + let word_part = entry.split('/').next().unwrap_or(entry).trim(); + if !word_part.is_empty() && !word_part.chars().all(|c| c.is_ascii_digit()) { + words.insert(word_part.to_lowercase()); + } + } + !words.is_empty() + } + + fn is_command_in_path(cmd: &str) -> bool { + if let Ok(path_var) = std::env::var("PATH") { + for dir in std::env::split_paths(&path_var) { + if dir.join(cmd).is_file() { + return true; + } + } + } + false + } + + fn check_via_cli(word: &str, lang: &str, want_suggestions: bool) -> Option<(bool, Vec)> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let lang_param = lang.replace('-', "_"); + let mut child = Command::new("hunspell") + .arg("-d") + .arg(&lang_param) + .arg("-a") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + if let Some(mut stdin) = child.stdin.take() { + let _ = writeln!(stdin, "^{word}"); + } + + let output = child.wait_with_output().ok()?; + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.starts_with('*') || line.starts_with('+') || line.starts_with('-') { + return Some((true, Vec::new())); + } + if line.starts_with('&') { + let mut suggs = Vec::new(); + if want_suggestions { + if let Some(pos) = line.find(':') { + for s in line[pos + 1..].split(',') { + let item = s.trim(); + if !item.is_empty() && suggs.len() < 5 { + suggs.push(item.to_string()); + } + } + } + } + return Some((false, suggs)); + } + } + None + } +} + +// ============================================================================ +// Fallback Backend +// ============================================================================ +mod fallback_impl { + pub struct FallbackSpeller; +} diff --git a/src/app/control/text_ops.rs b/src/app/control/text_ops.rs new file mode 100644 index 000000000..33ffe7256 --- /dev/null +++ b/src/app/control/text_ops.rs @@ -0,0 +1,1718 @@ +//! High-performance text search and replacement operations for OpenCADStudio. +//! +//! Provides headless, control-bridge, and MCP-accessible text querying and batch +//! replacement across Text, MText, Dimension, AttributeDefinition, and Insert entities. +//! Safely preserves MText inline formatting codes (fonts, colors, heights, braces) +//! and handles DXF Unicode escapes (`\U+XXXX`) and special character codes (`%%d`, `%%p`, etc.). + +use super::{failure, OpenCADStudio}; +use crate::app::Message; +use codec::{CadDocument, EntityType, Handle}; +use iced::Task; +use serde_json::{json, Value}; + +/// Decodes DXF special character codes (`%%d`, `%%p`, `%%c`, `%%nnn`) and +/// DXF Unicode escape sequences (`\U+XXXX` / `\u+XXXX`) into UTF-8 characters. +pub fn decode_dxf_escapes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '%' && chars.peek() == Some(&'%') { + chars.next(); // consume second '%' + match chars.peek().map(|ch| ch.to_ascii_lowercase()) { + Some('d') => { + chars.next(); + out.push('°'); + } + Some('p') => { + chars.next(); + out.push('±'); + } + Some('c') => { + chars.next(); + out.push('∅'); + } + Some('u') | Some('o') => { + chars.next(); // toggle codes — strip silently + } + Some('%') => { + chars.next(); + if chars.peek() == Some(&'%') { + chars.next(); + } + out.push('%'); + } + Some(d) if d.is_ascii_digit() => { + let mut digits = String::with_capacity(3); + for _ in 0..3 { + match chars.peek() { + Some(&ch) if ch.is_ascii_digit() => { + digits.push(chars.next().unwrap()); + } + _ => break, + } + } + if digits.len() == 3 { + if let Ok(n) = digits.parse::() { + let ch_opt = match n { + 128 => Some('€'), + 130 => Some('‚'), + 131 => Some('ƒ'), + 132 => Some('„'), + 133 => Some('…'), + 134 => Some('†'), + 135 => Some('‡'), + 136 => Some('ˆ'), + 137 => Some('‰'), + 138 => Some('Š'), + 139 => Some('‹'), + 140 => Some('Œ'), + 142 => Some('Ž'), + 145 => Some('‘'), + 146 => Some('’'), + 147 => Some('“'), + 148 => Some('”'), + 149 => Some('•'), + 150 => Some('–'), + 151 => Some('—'), + 152 => Some('˜'), + 153 => Some('™'), + 154 => Some('š'), + 155 => Some('›'), + 156 => Some('œ'), + 158 => Some('ž'), + 159 => Some('Ÿ'), + 160..=255 => char::from_u32(n), + _ => char::from_u32(n), + }; + if let Some(ch) = ch_opt { + out.push(ch); + continue; + } + } + } + out.push('%'); + out.push('%'); + out.push_str(&digits); + } + _ => { + out.push('%'); + out.push('%'); + } + } + continue; + } + + // DXF Unicode escape: \U+XXXX or \u+XXXX + if c == '\\' && (chars.peek() == Some(&'U') || chars.peek() == Some(&'u')) { + let mut clone = chars.clone(); + clone.next(); // consume U/u + if clone.peek() == Some(&'+') { + clone.next(); // consume '+' + let mut hex = String::with_capacity(4); + for _ in 0..4 { + match clone.peek() { + Some(&ch) if ch.is_ascii_hexdigit() => { + hex.push(clone.next().unwrap()); + } + _ => break, + } + } + if hex.len() == 4 { + if let Ok(codepoint) = u32::from_str_radix(&hex, 16) { + if let Some(ch) = char::from_u32(codepoint) { + out.push(ch); + chars = clone; // advance chars + continue; + } + } + } + } + } + + out.push(c); + } + + out +} + +/// Strips common Latin diacritics/accents into their plain ASCII equivalents (e.g. 'é' -> 'e', 'À' -> 'A'). +pub fn remove_diacritics(s: &str) -> String { + s.chars() + .map(|c| match c { + 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => 'a', + 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => 'A', + 'è' | 'é' | 'ê' | 'ë' => 'e', + 'È' | 'É' | 'Ê' | 'Ë' => 'E', + 'ì' | 'í' | 'î' | 'ï' => 'i', + 'Ì' | 'Í' | 'Î' | 'Ï' => 'I', + 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o', + 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' => 'O', + 'ù' | 'ú' | 'û' | 'ü' => 'u', + 'Ù' | 'Ú' | 'Û' | 'Ü' => 'U', + 'ý' | 'ÿ' => 'y', + 'Ý' => 'Y', + 'ç' => 'c', + 'Ç' => 'C', + 'ñ' => 'n', + 'Ñ' => 'N', + other => other, + }) + .collect() +} + +/// Converts LF / CRLF newlines into DXF MText paragraph breaks (`\P`). +pub fn to_raw_mtext_replacement(s: &str) -> String { + s.replace("\r\n", "\\P").replace('\n', "\\P") +} + +/// Converts raw DXF MText paragraph breaks (`\P` / `\p`) into plain text newlines (`\n`). +pub fn to_plain_text_search(s: &str) -> String { + s.replace("\\P", "\n").replace("\\p", "\n") +} + +/// Converts newlines (`\r\n` / `\n`) into raw DXF MText paragraph breaks (`\P`). +pub fn to_raw_mtext_search(s: &str) -> String { + s.replace("\r\n", "\\P").replace('\n', "\\P") +} + +/// Pattern matcher supporting case-sensitive/case-insensitive, whole-word, +/// and accent/diacritic-insensitive matching across Unicode and ASCII strings. +#[derive(Debug, Clone)] +pub struct TextMatcher { + pub search: String, + pub match_case: bool, + pub whole_word: bool, + pub ignore_accents: bool, +} + +impl TextMatcher { + pub fn new( + search: String, + match_case: bool, + whole_word: bool, + ignore_accents: bool, + ) -> Self { + Self { + search, + match_case, + whole_word, + ignore_accents, + } + } + + pub fn with_search(&self, search: String) -> Self { + Self { + search, + match_case: self.match_case, + whole_word: self.whole_word, + ignore_accents: self.ignore_accents, + } + } + + pub fn matches(&self, hay: &str) -> bool { + !self.find_all_in(hay).is_empty() + } + + /// Matches against both plain display text and raw CAD string, normalizing + /// newlines and MText paragraph break escapes (`\n` <-> `\P`). + pub fn matches_text_or_raw(&self, plain_text: &str, raw_val: &str) -> bool { + if self.matches(plain_text) || self.matches(raw_val) { + return true; + } + if self.search.contains('\n') || self.search.contains("\r\n") { + let p_matcher = self.with_search(to_raw_mtext_search(&self.search)); + if p_matcher.matches(raw_val) { + return true; + } + } + if self.search.contains("\\P") || self.search.contains("\\p") { + let lf_matcher = self.with_search(to_plain_text_search(&self.search)); + if lf_matcher.matches(plain_text) { + return true; + } + } + false + } + + /// Finds all non-overlapping match slices `(start_byte, end_byte)` in `hay`. + pub fn find_all_in(&self, hay: &str) -> Vec<(usize, usize)> { + if self.search.is_empty() || hay.is_empty() { + return Vec::new(); + } + + // Fast path: exact case-sensitive, accent-sensitive substring search + if self.match_case && !self.ignore_accents && !self.whole_word { + let mut matches = Vec::new(); + let mut cursor = 0; + while cursor < hay.len() { + if let Some(idx) = hay[cursor..].find(&self.search) { + let start = cursor + idx; + let end = start + self.search.len(); + matches.push((start, end)); + cursor = end; + } else { + break; + } + } + return matches; + } + + // Character-indexed search for case-insensitive, whole-word, and/or accent-insensitive matching + let hay_chars: Vec = hay.chars().collect(); + let mut byte_offsets = Vec::with_capacity(hay_chars.len() + 1); + let mut cur_byte = 0; + for &c in &hay_chars { + byte_offsets.push(cur_byte); + cur_byte += c.len_utf8(); + } + byte_offsets.push(cur_byte); + + let needle_norm: Vec = { + let s = if self.ignore_accents { + remove_diacritics(&self.search) + } else { + self.search.clone() + }; + if self.match_case { + s.chars().collect() + } else { + s.to_lowercase().chars().collect() + } + }; + + let hay_norm: Vec = { + let s = if self.ignore_accents { + remove_diacritics(hay) + } else { + hay.to_string() + }; + if self.match_case { + s.chars().collect() + } else { + s.to_lowercase().chars().collect() + } + }; + + let mut matches = Vec::new(); + let n_len = needle_norm.len(); + if n_len > 0 && n_len <= hay_norm.len() { + let mut i = 0; + while i + n_len <= hay_norm.len() { + if hay_norm[i..i + n_len] == needle_norm[..] { + let start_byte = byte_offsets[i]; + let end_byte = byte_offsets[i + n_len]; + + let is_word_boundary = if self.whole_word { + let before_ok = if i == 0 { + true + } else { + !is_word_char(hay_chars[i - 1]) + }; + let after_ok = if i + n_len >= hay_chars.len() { + true + } else { + !is_word_char(hay_chars[i + n_len]) + }; + before_ok && after_ok + } else { + true + }; + + if is_word_boundary { + matches.push((start_byte, end_byte)); + i += n_len; + continue; + } + } + i += 1; + } + } + + matches + } + + /// Replaces occurrences in `hay` with `replacement`. + pub fn replace_all_in( + &self, + hay: &str, + replacement: &str, + replace_all: bool, + ) -> (String, usize) { + let matches = self.find_all_in(hay); + if matches.is_empty() { + return (hay.to_string(), 0); + } + + let mut result = String::with_capacity(hay.len()); + let mut cursor = 0; + let mut count = 0; + + for (start, end) in matches { + result.push_str(&hay[cursor..start]); + result.push_str(replacement); + cursor = end; + count += 1; + if !replace_all { + break; + } + } + result.push_str(&hay[cursor..]); + (result, count) + } +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +fn has_trailing_paragraph_break(raw: &str) -> bool { + let s = raw.trim_end(); + let s = s.strip_suffix('}').unwrap_or(s); + match s.strip_suffix('P') { + Some(rest) => rest.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1, + None => false, + } +} + +/// Safely replaces text within an MText string by modifying text runs inside +/// the parsed MText structure, preserving all font tags, colors, and line breaks. +/// Supports both single-span replacements and multiline replacements across +/// paragraph breaks (`\P` / `\n`). +pub fn replace_mtext_safe( + raw: &str, + matcher: &TextMatcher, + replacement: &str, + replace_all: bool, +) -> (String, usize) { + if raw.is_empty() { + return (raw.to_string(), 0); + } + + // Convert replacement newlines to \P for MText compatibility + let raw_replacement = to_raw_mtext_replacement(replacement); + + let mut doc = codec::entities::mtext_format::parse_mtext(raw, true); + let mut total_replaced = 0; + + // First attempt: replace within individual parsed spans (preserves fine-grained formatting) + for para in &mut doc.paragraphs { + for span in &mut para.spans { + if span.stacking.is_none() { + let (new_text, count) = + matcher.replace_all_in(&span.text, &raw_replacement, replace_all); + if count > 0 { + span.text = new_text; + total_replaced += count; + if !replace_all { + break; + } + } + } + } + if !replace_all && total_replaced > 0 { + break; + } + } + + if total_replaced > 0 { + if has_trailing_paragraph_break(raw) { + doc.paragraphs + .push(codec::entities::mtext_format::MTextParagraph::new()); + } + (doc.to_mtext_string(), total_replaced) + } else { + // Second attempt: raw string replacement + // Handles multiline text where the search pattern spans across paragraph breaks (\P / \n) + replace_mtext_raw_flexible(raw, matcher, &raw_replacement, replace_all) + } +} + +/// Helper for flexible raw MText replacement handling newlines and paragraph breaks. +fn replace_mtext_raw_flexible( + raw: &str, + matcher: &TextMatcher, + raw_replacement: &str, + replace_all: bool, +) -> (String, usize) { + // 1. Try direct raw replacement with current matcher + let (direct_res, direct_count) = matcher.replace_all_in(raw, raw_replacement, replace_all); + if direct_count > 0 { + return (direct_res, direct_count); + } + + // 2. If search pattern contains newlines (\r\n or \n), convert them to \P and try + if matcher.search.contains('\n') || matcher.search.contains("\r\n") { + let p_search = to_raw_mtext_search(&matcher.search); + let p_matcher = matcher.with_search(p_search); + let (p_res, p_count) = p_matcher.replace_all_in(raw, raw_replacement, replace_all); + if p_count > 0 { + return (p_res, p_count); + } + + // 3. Multiline paragraph-boundary matching with flexible whitespace: + // Handles cases where raw has spaces around \P, or paragraph formatting like \pxqc; + let lines: Vec<&str> = matcher + .search + .split('\n') + .map(|l| l.strip_suffix('\r').unwrap_or(l)) + .collect(); + + if lines.len() >= 2 { + let (flex_res, flex_count) = + replace_raw_lines(raw, &lines, matcher, raw_replacement, replace_all); + if flex_count > 0 { + return (flex_res, flex_count); + } + } + } else if matcher.search.contains("\\P") || matcher.search.contains("\\p") { + // Search contained literal \P; try with \n in case raw has raw LF + let lf_search = to_plain_text_search(&matcher.search); + let lf_matcher = matcher.with_search(lf_search); + let (lf_res, lf_count) = lf_matcher.replace_all_in(raw, raw_replacement, replace_all); + if lf_count > 0 { + return (lf_res, lf_count); + } + } + + (raw.to_string(), 0) +} + +/// Helper to match line sequences across raw MText paragraph breaks (\P / \p / tags / spaces). +fn replace_raw_lines( + raw: &str, + lines: &[&str], + matcher: &TextMatcher, + raw_replacement: &str, + replace_all: bool, +) -> (String, usize) { + if lines.len() < 2 || raw.is_empty() { + return (raw.to_string(), 0); + } + + let line0 = lines[0].trim_end(); + let line_last = lines[lines.len() - 1].trim_start(); + let middle_lines: Vec<&str> = lines[1..lines.len() - 1].iter().map(|l| l.trim()).collect(); + + let m0 = matcher.with_search(line0.to_string()); + let m_last = matcher.with_search(line_last.to_string()); + let m_mids: Vec = middle_lines + .iter() + .map(|l| matcher.with_search(l.to_string())) + .collect(); + + let matches0 = m0.find_all_in(raw); + if matches0.is_empty() { + return (raw.to_string(), 0); + } + + let mut result = String::with_capacity(raw.len()); + let mut cursor = 0; + let mut count = 0; + + for (start0, end0) in matches0 { + if start0 < cursor { + continue; // already replaced + } + + // Try to verify subsequent lines separated by MText paragraph breaks + let mut check_pos = end0; + let mut matched_all = true; + + for m_mid in &m_mids { + if let Some(next_pos) = skip_mtext_paragraph_break(raw, check_pos) { + let rest = &raw[next_pos..]; + let mid_matches = m_mid.find_all_in(rest); + if let Some(&(m_s, m_e)) = mid_matches.first() { + if m_s == 0 { + check_pos = next_pos + m_e; + continue; + } + } + } + matched_all = false; + break; + } + + if !matched_all { + continue; + } + + if let Some(next_pos) = skip_mtext_paragraph_break(raw, check_pos) { + let rest = &raw[next_pos..]; + let last_matches = m_last.find_all_in(rest); + if let Some(&(l_s, l_e)) = last_matches.first() { + if l_s == 0 { + let end_pos = next_pos + l_e; + result.push_str(&raw[cursor..start0]); + result.push_str(raw_replacement); + cursor = end_pos; + count += 1; + if !replace_all { + break; + } + continue; + } + } + } + } + + if count > 0 { + result.push_str(&raw[cursor..]); + (result, count) + } else { + (raw.to_string(), 0) + } +} + +/// Skips over an MText paragraph break: optional whitespace, `\P` or `\p`, optional format tags like `\pxqc;`, optional whitespace. +fn skip_mtext_paragraph_break(raw: &str, from: usize) -> Option { + if from >= raw.len() { + return None; + } + let bytes = raw.as_bytes(); + let mut i = from; + + // Skip whitespace before \P + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + + // Must have \P or \p + if i + 1 < bytes.len() && bytes[i] == b'\\' && (bytes[i + 1] == b'P' || bytes[i + 1] == b'p') { + i += 2; + } else { + return None; + } + + // Skip optional paragraph format tags like \pxqc; or \pi...; + if i < bytes.len() && bytes[i] == b'\\' { + let mut tag_end = i + 1; + while tag_end < bytes.len() + && bytes[tag_end] != b';' + && bytes[tag_end] != b'\\' + && bytes[tag_end] != b'{' + && bytes[tag_end] != b'}' + { + tag_end += 1; + } + if tag_end < bytes.len() && bytes[tag_end] == b';' { + i = tag_end + 1; + } + } + + // Skip whitespace after \P + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + + Some(i) +} + +/// Extracts the plain display text and position for an entity. +pub fn entity_text_info( + entity: &EntityType, + document: &CadDocument, +) -> Option<(String, String, [f64; 3])> { + // Returns (raw_value, plain_text, [x, y, z]) + match entity { + EntityType::Text(t) => { + let raw = t.value.clone(); + let plain = decode_dxf_escapes(&raw); + let pos = [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::MText(t) => { + let raw = t.value.clone(); + let plain_raw = codec::entities::mtext_format::parse_mtext(&raw, true).to_plain_text(); + let plain = decode_dxf_escapes(&plain_raw); + let pos = [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::AttributeDefinition(ad) => { + let raw = ad.default_value.clone(); + let plain = decode_dxf_escapes(&raw); + let pos = [ad.insertion_point.x, ad.insertion_point.y, ad.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::AttributeEntity(ae) => { + let raw = ae.get_value().to_string(); + let plain = decode_dxf_escapes(&raw); + let pos = [ae.insertion_point.x, ae.insertion_point.y, ae.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::Dimension(dim) => { + let text = dim.base().text.clone(); + if text.is_empty() { + None + } else { + let plain = decode_dxf_escapes(&text); + let p = dim.base().text_middle_point; + Some((text, plain, [p.x, p.y, p.z])) + } + } + _ => { + let _ = document; + None + } + } +} + +fn parse_bounds_filter(req: &Value) -> Result, Value> { + let Some(bounds_val) = req.get("bounds") else { + return Ok(None); + }; + let bounds = bounds_val.as_array().and_then(|values| { + if values.len() != 4 { + return None; + } + let min_x = values[0].as_f64()?; + let min_y = values[1].as_f64()?; + let max_x = values[2].as_f64()?; + let max_y = values[3].as_f64()?; + Some([min_x, min_y, max_x, max_y]) + }); + let Some(bounds) = bounds else { + return Err(failure( + "invalid_bounds", + "bounds expects [min_x, min_y, max_x, max_y]", + )); + }; + if !bounds.iter().all(|v| v.is_finite()) || bounds[0] > bounds[2] || bounds[1] > bounds[3] { + return Err(failure( + "invalid_bounds", + "bounds must be finite with min <= max", + )); + } + Ok(Some(bounds)) +} + +fn parse_handles_filter(req: &Value) -> Option> { + if let Some(h) = req["handle"].as_str() { + u64::from_str_radix(h.trim_start_matches("0x"), 16) + .ok() + .map(|val| vec![Handle::new(val)]) + } else if let Some(arr) = req["handles"].as_array() { + let handles: Vec<_> = arr + .iter() + .filter_map(|v| { + let s = v.as_str()?; + u64::from_str_radix(s.trim_start_matches("0x"), 16) + .ok() + .map(Handle::new) + }) + .collect(); + (!handles.is_empty()).then_some(handles) + } else { + None + } +} + +fn parse_types_filter(req: &Value) -> Option> { + if let Some(t) = req["type"].as_str() { + Some(vec![t.to_string()]) + } else if let Some(arr) = req["types"].as_array() { + let types: Vec<_> = arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + (!types.is_empty()).then_some(types) + } else { + None + } +} + +fn entity_block_name(doc: &CadDocument, entity_handle: Handle, owner: Handle) -> Option { + if let Some(record) = doc.block_records.iter().find(|r| r.handle == owner) { + return Some(record.name.clone()); + } + for record in doc.block_records.iter() { + if record.entity_handles.contains(&entity_handle) { + return Some(record.name.clone()); + } + } + None +} + +impl OpenCADStudio { + /// `text_search` — find text occurrences across active space entities, inserts, + /// and block definitions with detailed machine-readable metadata. + pub(crate) fn control_text_search(&self, req: &Value) -> Result { + let find = req["find"] + .as_str() + .or_else(|| req["search"].as_str()) + .unwrap_or(""); + if find.is_empty() { + return Err(failure("missing_find", "Supply 'find' parameter")); + } + + let match_case = req["match_case"].as_bool().unwrap_or(false); + let whole_word = req["whole_word"].as_bool().unwrap_or(false); + let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let scope = req["scope"].as_str().unwrap_or("all"); + let layer_filter = req["layer"].as_str(); + let handles_filter = parse_handles_filter(req); + let types_filter = parse_types_filter(req); + let bounds_filter = parse_bounds_filter(req)?; + let limit = req["limit"].as_u64().unwrap_or(500).min(5000) as usize; + + let matcher = TextMatcher::new(find.to_string(), match_case, whole_word, ignore_accents); + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; + let scene = &self.tabs[i].scene; + let doc = &scene.document; + + let mut matches = Vec::new(); + + for entity in doc.entities() { + let common = entity.common(); + let handle = common.handle; + + // Scope filter + let in_active = scene.entity_belongs_to_active_space(handle); + match scope { + "active_space" | "model_space" if !in_active => continue, + "blocks" if in_active => continue, + _ => {} + } + + // Layer filter + if let Some(l) = layer_filter { + if !common.layer.eq_ignore_ascii_case(l) { + continue; + } + } + + // Handle filter + if let Some(ref h_list) = handles_filter { + if !h_list.contains(&handle) { + continue; + } + } + + // Types filter + if let Some(ref t_list) = types_filter { + if !t_list + .iter() + .any(|t| crate::app::automation::entity_type_matches(entity, t)) + { + continue; + } + } + + // Bounds filter + if let Some(b) = bounds_filter { + let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity); + if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] { + continue; + } + } + + let block_name = entity_block_name(doc, handle, common.owner_handle); + + // Check standard text entities + if let Some((raw_val, plain_text, pos)) = entity_text_info(entity, doc) { + if matcher.matches_text_or_raw(&plain_text, &raw_val) { + let matched_text = find.to_string(); + matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "space": if in_active { "active" } else { "definition" }, + "block": block_name, + "position": pos, + "raw_value": raw_val, + "plain_text": plain_text, + "match_text": matched_text, + })); + if matches.len() >= limit { + break; + } + } + } + + // Check Insert attributes + if let EntityType::Insert(insert) = entity { + for (idx, attr) in insert.attributes.iter().enumerate() { + let raw = attr.get_value(); + let plain = decode_dxf_escapes(raw); + if matcher.matches_text_or_raw(&plain, raw) { + matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "layer": common.layer, + "space": if in_active { "active" } else { "definition" }, + "block": insert.block_name, + "attribute_tag": attr.tag, + "attribute_index": idx, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "raw_value": raw, + "plain_text": plain, + "match_text": find, + })); + if matches.len() >= limit { + break; + } + } + } + if matches.len() >= limit { + break; + } + } + } + + let truncated = matches.len() >= limit; + Ok(json!({ + "ok": true, + "count": matches.len(), + "truncated": truncated, + "matches": matches, + })) + } + + /// `text_audit` — comprehensive text quality check, spell-check, and replacement dry-run. + pub(crate) fn control_text_audit(&self, req: &Value) -> Result { + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; + + let match_case = req["match_case"].as_bool().unwrap_or(false); + let whole_word = req["whole_word"].as_bool().unwrap_or(false); + let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let scope = req["scope"].as_str().unwrap_or("all"); + let layer_filter = req["layer"].as_str(); + let handles_filter = parse_handles_filter(req); + let types_filter = parse_types_filter(req); + let bounds_filter = parse_bounds_filter(req)?; + let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; + + // Parse dry-run replacement pairs if supplied + let mut pairs: Vec<(String, String)> = Vec::new(); + let pairs_val = req.get("pairs").or_else(|| req.get("dry_run_pairs")); + if let Some(pair_array) = pairs_val.and_then(|v| v.as_array()) { + for item in pair_array { + let find = item["find"].as_str().unwrap_or(""); + let replace = item["replace"].as_str().unwrap_or(""); + if !find.is_empty() { + pairs.push((find.to_string(), replace.to_string())); + } + } + } + + // Parse check terms if supplied + let mut check_terms: Vec = Vec::new(); + if let Some(terms_array) = req.get("check_terms").and_then(|v| v.as_array()) { + for item in terms_array { + if let Some(t) = item.as_str() { + if !t.is_empty() { + check_terms.push(t.to_string()); + } + } + } + } + + // Parse dictionary if supplied + let mut dictionary: Vec = Vec::new(); + if let Some(dict_array) = req.get("dictionary").and_then(|v| v.as_array()) { + for item in dict_array { + if let Some(w) = item.as_str() { + if !w.is_empty() { + dictionary.push(w.to_string()); + } + } + } + } + + let pair_matchers: Vec<(TextMatcher, String)> = pairs + .iter() + .map(|(f, r)| { + ( + TextMatcher::new(f.clone(), match_case, whole_word, ignore_accents), + r.clone(), + ) + }) + .collect(); + + let check_matchers: Vec = check_terms + .iter() + .map(|t| TextMatcher::new(t.clone(), match_case, whole_word, ignore_accents)) + .collect(); + + let dict_matchers: Vec = dictionary + .iter() + .map(|w| TextMatcher::new(w.clone(), false, true, true)) + .collect(); + + let use_system_speller = req["system_spellcheck"] + .as_bool() + .or_else(|| req["use_system_speller"].as_bool()) + .or_else(|| req["system_speller"].as_bool()) + .unwrap_or(false); + let language_param = req["language"].as_str(); + let want_suggestions = req["suggest"].as_bool().unwrap_or(true); + + let system_speller = if use_system_speller { + Some(super::spellcheck::SystemSpeller::new(language_param)) + } else { + None + }; + + let mut pair_hit_counts = vec![0usize; pair_matchers.len()]; + let mut check_hit_counts = vec![0usize; check_matchers.len()]; + + let scene = &self.tabs[i].scene; + let doc = &scene.document; + + let mut entities_scanned = 0usize; + let mut entities_matched = 0usize; + let mut total_occurrences = 0usize; + let mut simulated_changes = Vec::new(); + let mut suspect_matches = Vec::new(); + let mut unrecognized_words = Vec::new(); + + for entity in doc.entities() { + let common = entity.common(); + let handle = common.handle; + + let in_active = scene.entity_belongs_to_active_space(handle); + match scope { + "active_space" | "model_space" if !in_active => continue, + "blocks" if in_active => continue, + _ => {} + } + + if let Some(l) = layer_filter { + if !common.layer.eq_ignore_ascii_case(l) { + continue; + } + } + + if let Some(ref h_list) = handles_filter { + if !h_list.contains(&handle) { + continue; + } + } + + if let Some(ref t_list) = types_filter { + if !t_list + .iter() + .any(|t| crate::app::automation::entity_type_matches(entity, t)) + { + continue; + } + } + + if let Some(b) = bounds_filter { + let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity); + if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] { + continue; + } + } + + let Some((raw_val, plain_text, pos)) = entity_text_info(entity, doc) else { + continue; + }; + + entities_scanned += 1; + let mut entity_had_match = false; + + // 1. Dry run pairs simulation + if !pair_matchers.is_empty() { + match entity { + EntityType::MText(_) => { + let mut cur_raw = raw_val.clone(); + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_raw, count) = + replace_mtext_safe(&cur_raw, matcher, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "MText", + "layer": common.layer, + "position": pos, + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": cur_raw, + "after": new_raw.clone(), + "replaced": count, + })); + } + cur_raw = new_raw; + } + } + } + _ => { + let mut cur_text = plain_text.clone(); + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_text, count) = + matcher.replace_all_in(&cur_text, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "position": pos, + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": cur_text, + "after": new_text.clone(), + "replaced": count, + })); + } + cur_text = new_text; + } + } + } + } + } + + // 2. Check terms + for (idx, matcher) in check_matchers.iter().enumerate() { + if matcher.matches_text_or_raw(&plain_text, &raw_val) { + check_hit_counts[idx] += 1; + entity_had_match = true; + if suspect_matches.len() < limit { + suspect_matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "position": pos, + "term": check_terms[idx], + "plain_text": plain_text, + })); + } + } + } + + // 3. Dictionary & System Spell-check + if !dict_matchers.is_empty() || system_speller.is_some() { + for word in plain_text.split(|c: char| !c.is_alphabetic()) { + let w = word.trim(); + if w.len() >= 3 { + let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w)); + if in_agent_dict { + continue; + } + + let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller { + speller.check_word(w, want_suggestions) + } else { + (false, Vec::new()) + }; + + if !is_system_valid { + entity_had_match = true; + if unrecognized_words.len() < limit + && !unrecognized_words + .iter() + .any(|item: &Value| item["word"].as_str() == Some(w)) + { + let mut item = json!({ + "word": w, + "handle": format!("{:X}", handle.value()), + "position": pos, + }); + if !suggestions.is_empty() { + item["suggestions"] = json!(suggestions); + } + unrecognized_words.push(item); + } + } + } + } + } + + // Check Insert attributes + if let EntityType::Insert(insert) = entity { + for attr in &insert.attributes { + let raw = attr.get_value(); + let plain = decode_dxf_escapes(raw); + + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_text, count) = + matcher.replace_all_in(&plain, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "layer": common.layer, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": plain.clone(), + "after": new_text, + "replaced": count, + })); + } + } + } + + for (idx, matcher) in check_matchers.iter().enumerate() { + if matcher.matches_text_or_raw(&plain, raw) { + check_hit_counts[idx] += 1; + entity_had_match = true; + if suspect_matches.len() < limit { + suspect_matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "layer": common.layer, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "term": check_terms[idx], + "plain_text": plain, + })); + } + } + } + + if !dict_matchers.is_empty() || system_speller.is_some() { + for word in plain.split(|c: char| !c.is_alphabetic()) { + let w = word.trim(); + if w.len() >= 3 { + let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w)); + if in_agent_dict { + continue; + } + + let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller { + speller.check_word(w, want_suggestions) + } else { + (false, Vec::new()) + }; + + if !is_system_valid { + entity_had_match = true; + if unrecognized_words.len() < limit + && !unrecognized_words + .iter() + .any(|item: &Value| item["word"].as_str() == Some(w)) + { + let mut item = json!({ + "word": w, + "handle": format!("{:X}", handle.value()), + "attribute_tag": attr.tag, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + }); + if !suggestions.is_empty() { + item["suggestions"] = json!(suggestions); + } + unrecognized_words.push(item); + } + } + } + } + } + } + } + + if entity_had_match { + entities_matched += 1; + } + } + + // Unmatched pairs / queries + let unmatched_pairs: Vec = pair_hit_counts + .iter() + .enumerate() + .filter(|(_, &count)| count == 0) + .map(|(idx, _)| pairs[idx].0.clone()) + .collect(); + + let unmatched_check_terms: Vec = check_hit_counts + .iter() + .enumerate() + .filter(|(_, &count)| count == 0) + .map(|(idx, _)| check_terms[idx].clone()) + .collect(); + + let speller_info = if let Some(ref speller) = system_speller { + json!({ + "enabled": true, + "available": speller.is_available(), + "backend": speller.backend_name(), + "language": speller.language(), + }) + } else { + json!({ + "enabled": false, + "available": false, + "backend": "none", + "language": null, + }) + }; + + Ok(json!({ + "ok": true, + "summary": { + "entities_scanned": entities_scanned, + "entities_matched": entities_matched, + "occurrences_matched": total_occurrences, + "pairs_total": pairs.len(), + "pairs_unmatched": unmatched_pairs.len(), + "check_terms_total": check_terms.len(), + "check_terms_found": check_terms.len() - unmatched_check_terms.len(), + "unrecognized_words_count": unrecognized_words.len(), + }, + "system_speller": speller_info, + "unmatched_pairs": unmatched_pairs, + "unmatched_check_terms": unmatched_check_terms, + "simulated_changes": simulated_changes, + "suspect_matches": suspect_matches, + "unrecognized_words": unrecognized_words, + })) + } + + /// Execute text replace across candidates with single-transaction undo support. + pub(crate) fn execute_text_replace( + &mut self, + req: &Value, + push_undo: bool, + ) -> Result { + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; + + // Parse search/replace pairs + let mut pairs: Vec<(String, String)> = Vec::new(); + if let Some(pair_array) = req["pairs"].as_array() { + for item in pair_array { + let find = item["find"].as_str().unwrap_or(""); + let replace = item["replace"].as_str().unwrap_or(""); + if !find.is_empty() { + pairs.push((find.to_string(), replace.to_string())); + } + } + } else if let Some(find) = req["find"].as_str() { + if !find.is_empty() { + let replace = req["replace"].as_str().unwrap_or(""); + pairs.push((find.to_string(), replace.to_string())); + } + } + + if pairs.is_empty() { + return Err(failure( + "missing_find", + "Supply 'find'/'replace' or 'pairs' for text_replace", + )); + } + + let match_case = req["match_case"].as_bool().unwrap_or(false); + let whole_word = req["whole_word"].as_bool().unwrap_or(false); + let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let dry_run = req["dry_run"].as_bool().unwrap_or(false); + let replace_all = req["replace_all"].as_bool().unwrap_or(true); + let scope = req["scope"].as_str().unwrap_or("all"); + let layer_filter = req["layer"].as_str(); + let handles_filter = parse_handles_filter(req); + let types_filter = parse_types_filter(req); + let bounds_filter = parse_bounds_filter(req)?; + + let matchers: Vec<(TextMatcher, String)> = pairs + .into_iter() + .map(|(f, r)| (TextMatcher::new(f, match_case, whole_word, ignore_accents), r)) + .collect(); + + if push_undo && !dry_run { + self.push_undo_snapshot(i, "TEXT REPLACE"); + } + + let mut total_replaced = 0usize; + let mut changed_handles = Vec::new(); + let mut changes_log = Vec::new(); + + // Collect all entity handles that match filters + let candidate_handles: Vec = { + let scene = &self.tabs[i].scene; + let doc = &scene.document; + let mut candidates = Vec::new(); + for entity in doc.entities() { + let common = entity.common(); + let handle = common.handle; + + let in_active = scene.entity_belongs_to_active_space(handle); + match scope { + "active_space" | "model_space" if !in_active => continue, + "blocks" if in_active => continue, + _ => {} + } + + if let Some(l) = layer_filter { + if !common.layer.eq_ignore_ascii_case(l) { + continue; + } + } + + if let Some(ref h_list) = handles_filter { + if !h_list.contains(&handle) { + continue; + } + } + + if let Some(ref t_list) = types_filter { + if !t_list + .iter() + .any(|t| crate::app::automation::entity_type_matches(entity, t)) + { + continue; + } + } + + if let Some(b) = bounds_filter { + let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity); + if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] { + continue; + } + } + + if scene.is_layer_locked(handle) { + continue; + } + + candidates.push(handle); + } + candidates + }; + + // Mutate matching entities + let doc = &mut self.tabs[i].scene.document; + for handle in candidate_handles { + let Some(entity) = doc.get_entity_mut(handle) else { + continue; + }; + + let mut entity_replaced = 0; + let mut before_val = None; + let mut after_val = None; + + match entity { + EntityType::Text(text) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&text.value, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(text.value.clone()); + } + if !dry_run { + text.value = new_val.clone(); + } + entity_replaced += count; + after_val = Some(new_val); + } else { + // Check if matching decoded DXF escapes + let decoded = decode_dxf_escapes(&text.value); + if matcher.matches(&decoded) { + let (new_val, c) = + matcher.replace_all_in(&decoded, replacement, replace_all); + if c > 0 { + if before_val.is_none() { + before_val = Some(text.value.clone()); + } + if !dry_run { + text.value = new_val.clone(); + } + entity_replaced += c; + after_val = Some(new_val); + } + } + } + } + } + EntityType::MText(mtext) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + replace_mtext_safe(&mtext.value, matcher, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(mtext.value.clone()); + } + if !dry_run { + mtext.value = new_val.clone(); + } + entity_replaced += count; + after_val = Some(new_val); + } + } + } + EntityType::AttributeDefinition(ad) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&ad.default_value, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(ad.default_value.clone()); + } + if !dry_run { + ad.default_value = new_val.clone(); + } + entity_replaced += count; + after_val = Some(new_val); + } + } + } + EntityType::AttributeEntity(ae) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(ae.get_value(), replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(ae.get_value().to_string()); + } + if !dry_run { + ae.set_value(new_val.clone()); + } + entity_replaced += count; + after_val = Some(new_val); + } + } + } + EntityType::Dimension(dim) => { + let base = dim.base_mut(); + if !base.text.is_empty() { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&base.text, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(base.text.clone()); + } + if !dry_run { + base.text = new_val.clone(); + } + entity_replaced += count; + after_val = Some(new_val); + } + } + } + } + EntityType::Insert(insert) => { + for attr in &mut insert.attributes { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(attr.get_value(), replacement, replace_all); + if count > 0 { + let old_val = attr.get_value().to_string(); + if !dry_run { + attr.set_value(new_val.clone()); + } + total_replaced += count; + changes_log.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "before": old_val, + "after": new_val, + "replaced": count, + })); + if !changed_handles.contains(&handle) { + changed_handles.push(handle); + } + } + } + } + } + _ => {} + } + + if entity_replaced > 0 { + total_replaced += entity_replaced; + changes_log.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "before": before_val, + "after": after_val, + "replaced": entity_replaced, + })); + if !changed_handles.contains(&handle) { + changed_handles.push(handle); + } + } + } + + if total_replaced == 0 { + if push_undo && !dry_run { + self.discard_last_undo_entry(i); + } + return Ok(json!({ + "ok": true, + "dry_run": dry_run, + "replaced": 0, + "entities_changed": 0, + "changes": [], + })); + } + + if !dry_run { + // Refresh scene caches and dirty flags + self.invalidate_property_targets(i, &changed_handles); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.refresh_properties(); + + self.command_line.push_output( + format!( + "TEXT REPLACE: replaced {total_replaced} occurrence(s) in {} object(s).", + changed_handles.len() + ) + .as_str(), + ); + } + + Ok(json!({ + "ok": true, + "dry_run": dry_run, + "replaced": total_replaced, + "entities_changed": changed_handles.len(), + "changes": changes_log, + })) + } + + /// Bridge for `control_request` mutating op `"text_replace"`. + pub(super) fn control_text_replace(&mut self, req: &Value) -> Result, Value> { + let result = self.execute_text_replace(req, true)?; + self.set_control_result(result); + Ok(Task::none()) + } + + /// Headless automation text search. + pub(crate) fn automation_text_search(&self, req: &Value) -> Value { + self.control_text_search(req).unwrap_or_else(|e| e) + } + + /// Headless automation text audit. + pub(crate) fn automation_text_audit(&self, req: &Value) -> Value { + self.control_text_audit(req).unwrap_or_else(|e| e) + } + + /// Headless automation text replace. + pub(crate) fn automation_text_replace(&mut self, req: &Value) -> Value { + self.execute_text_replace(req, true).unwrap_or_else(|e| e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_dxf_escapes() { + assert_eq!(decode_dxf_escapes("CIRCUITS"), "CIRCUITS"); + assert_eq!(decode_dxf_escapes("R\\U+00C9SERVOIR"), "RÉSERVOIR"); + assert_eq!(decode_dxf_escapes("r\\u+00e9servoir"), "réservoir"); + assert_eq!(decode_dxf_escapes("Angle %%d"), "Angle °"); + assert_eq!(decode_dxf_escapes("Tolerance %%p0.05"), "Tolerance ±0.05"); + assert_eq!(decode_dxf_escapes("Diameter %%c50"), "Diameter ∅50"); + assert_eq!(decode_dxf_escapes("100%%%%"), "100%"); + // Windows-1252 / ISO-8859-1 code decoding + assert_eq!(decode_dxf_escapes("Chaudi%%232re"), "Chaudière"); + assert_eq!(decode_dxf_escapes("St%%233 EVERWASH"), "Sté EVERWASH"); + } + + #[test] + fn test_text_matcher_case_and_word_boundaries() { + let tm_ci = TextMatcher::new("bar".into(), false, false, false); + assert!(tm_ci.matches("BAROMETRE")); + assert!(tm_ci.matches("bar")); + assert!(tm_ci.matches("BAR")); + + let tm_ww = TextMatcher::new("bar".into(), false, true, false); + assert!(!tm_ww.matches("BAROMETRE")); + assert!(!tm_ww.matches("MINIBAR")); + assert!(tm_ww.matches("BAR")); + assert!(tm_ww.matches("P = 10 BAR")); + assert!(tm_ww.matches("BAR;")); + + let tm_cs = TextMatcher::new("Bar".into(), true, false, false); + assert!(!tm_cs.matches("BAR")); + assert!(tm_cs.matches("Barometer")); + + let (replaced, count) = tm_ww.replace_all_in("10 BAR ET 20 BAROMETRE", "PSI", true); + assert_eq!(count, 1); + assert_eq!(replaced, "10 PSI ET 20 BAROMETRE"); + } + + #[test] + fn test_text_matcher_accent_insensitive() { + let tm_acc = TextMatcher::new("reservoir".into(), false, false, true); + assert!(tm_acc.matches("RÉSERVOIR")); + assert!(tm_acc.matches("Réservoir")); + assert!(tm_acc.matches("reservoir")); + + let tm_ch = TextMatcher::new("chaudiere".into(), false, false, true); + assert!(tm_ch.matches("Chaudière 1")); + let (replaced, count) = tm_ch.replace_all_in("Chaudière 1 et Chaudière 2", "Boiler", true); + assert_eq!(count, 2); + assert_eq!(replaced, "Boiler 1 et Boiler 2"); + + let tm_bache = TextMatcher::new("bache a eau".into(), false, false, true); + assert!(tm_bache.matches("Bâche à eau 2")); + } + + #[test] + fn test_replace_mtext_safe_preserves_formatting() { + let raw = "{\\fCentury Gothic|b0|i0|c0|p34;V15-CUVE EAU NON TRAITE}"; + let matcher = TextMatcher::new("NON TRAITE".into(), false, false, false); + let (replaced, count) = replace_mtext_safe(raw, &matcher, "NON TRAITÉE", true); + assert_eq!(count, 1); + assert!(replaced.contains("NON TRAITÉE")); + assert!(replaced.contains("Century Gothic")); + assert!(!replaced.contains("NON TRAITE}")); + } + + #[test] + fn test_replace_mtext_multiline() { + // Test 1: Plain LF query matching raw \P + let raw1 = "Loge \\Pgardien"; + let matcher1 = TextMatcher::new("Loge \ngardien".into(), false, false, true); + let (replaced1, count1) = replace_mtext_safe(raw1, &matcher1, "Security \nGuard Booth", true); + assert_eq!(count1, 1); + assert_eq!(replaced1, "Security \\PGuard Booth"); + + // Test 2: Multiline with complex formatting codes preserved + let raw2 = "\\pxqc;{\\fCentury Gothic|b1|i0|c0|p34;ACCÈS ET SORTIE\\PMARCHANDISES}"; + let matcher2 = TextMatcher::new("ACCÈS ET SORTIE\nMARCHANDISES".into(), false, false, true); + let (replaced2, count2) = replace_mtext_safe(raw2, &matcher2, "GOODS ACCESS AND EXIT", true); + assert_eq!(count2, 1); + assert!(replaced2.contains("GOODS ACCESS AND EXIT")); + assert!(replaced2.contains("\\pxqc;")); + assert!(replaced2.contains("Century Gothic")); + + // Test 3: Multiline with paragraph properties and flexible whitespace + let raw3 = "{\\pqc;Office \\PWAREHOUSE}"; + let matcher3 = TextMatcher::new("Office \nWAREHOUSE".into(), false, false, true); + let (replaced3, count3) = replace_mtext_safe(raw3, &matcher3, "WAREHOUSE OFFICE", true); + assert_eq!(count3, 1); + assert_eq!(replaced3, "{\\pqc;WAREHOUSE OFFICE}"); + } +} + diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index be0cbea77..d64184dd3 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -30,13 +30,28 @@ fn to_render(ell: &Ellipse) -> RenderEntity { .map(crate::entities::curve::snap_from) .unwrap_or_default(); + let is_full = ell.is_full() + || (ell.end_parameter - ell.start_parameter).abs() >= std::f64::consts::TAU - 1e-9; + let (start_param, end_param) = if is_full { + (0.0, std::f64::consts::TAU) + } else { + let s = ell.start_parameter.rem_euclid(std::f64::consts::TAU); + let raw = ell.end_parameter - ell.start_parameter; + let sweep = if raw <= 0.0 { + raw.rem_euclid(std::f64::consts::TAU) + } else { + raw + }; + (s, s + sweep) + }; + let tangent = TangentGeom::PlanarEllipse { center: [ell.center.x, ell.center.y, ell.center.z], major_axis: [ell.major_axis.x, ell.major_axis.y, ell.major_axis.z], normal: [ell.normal.x, ell.normal.y, ell.normal.z], minor_axis_ratio: ell.minor_axis_ratio, - start_param: ell.start_parameter, - end_param: ell.end_parameter, + start_param, + end_param, }; // The points come from the entity's own kernel curve and angular policy. @@ -339,18 +354,12 @@ fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) { } fn apply_transform(ell: &mut Ellipse, t: &EntityTransform) { + // The codec maps the whole ellipse — ratio, principal axes and arc + // parameters — under rotate, scale, affine and mirror alike. crate::scene::view::transform::apply_standard_entity_transform(ell, t, |entity, p1, p2| { - crate::scene::view::transform::reflect_xy_point( - &mut entity.center.x, - &mut entity.center.y, - p1, - p2, - ); - crate::scene::view::transform::reflect_xy_point( - &mut entity.major_axis.x, - &mut entity.major_axis.y, - p1, - p2, + codec::Entity::apply_mirror( + entity, + &crate::scene::view::transform::reflection_about_xy_line(p1, p2), ); }); } @@ -487,4 +496,53 @@ mod grip_tests { "minor still 10 — no ballooning" ); } + + #[test] + fn test_apply_transform_negative_scale_and_rotation() { + let mut e = ell(10.0, 0.5); + e.start_parameter = 0.0; + e.end_parameter = std::f64::consts::FRAC_PI_2; + + let rot = EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::Z, + angle_rad: -std::f64::consts::FRAC_PI_4, + }; + apply_transform(&mut e, &rot); + + let scale = EntityTransform::Scale { + center: DVec3::ZERO, + factor: -1.0, + }; + apply_transform(&mut e, &scale); + + assert!((xy_len(&e) - 10.0).abs() < 1e-9); + assert!((e.minor_axis_ratio - 0.5).abs() < 1e-9); + assert!((e.normal.z - 1.0).abs() < 1e-9); + // Start parameter was 0, rotated by -45 then scaled by -1 (180 rotation) -> net effect + assert!(e.start_parameter >= 0.0 && e.start_parameter < std::f64::consts::TAU); + } + + #[test] + fn test_apply_transform_mirror() { + let mut e = ell(10.0, 0.5); + e.start_parameter = 0.1; + e.end_parameter = 1.5; + + // Mirror across Y axis (p1=(0,0,0), p2=(0,1,0)) + let mirror = EntityTransform::Mirror { + p1: DVec3::ZERO, + p2: DVec3::Y, + working_normal: DVec3::Z, + }; + apply_transform(&mut e, &mirror); + + // Major axis reflected to (-10, 0, 0); MIRROR keeps the normal facing + // the mirrored plane and negates the parameters instead. + assert!((e.major_axis.x - (-10.0)).abs() < 1e-9); + assert!((e.normal.z - 1.0).abs() < 1e-9); + let (s, end) = (e.start_parameter, e.end_parameter); + assert!((s - (std::f64::consts::TAU - 1.5)).abs() < 1e-9, "{s}"); + assert!((end - s - 1.4).abs() < 1e-9, "{end}"); + } } diff --git a/src/gpu_backend.rs b/src/gpu_backend.rs index a11f211e8..8b5141cd1 100644 --- a/src/gpu_backend.rs +++ b/src/gpu_backend.rs @@ -319,17 +319,51 @@ pub fn crashed_backends() -> Vec { return Vec::new(); }; let text = std::fs::read_to_string(p).unwrap_or_default(); + let named = || -> Vec { + text.lines() + .find_map(|l| l.strip_prefix("backend=")) + .unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }; if text.lines().any(|l| l.trim() == SURVIVED_MARKER) { - return Vec::new(); + // Surviving the proof delay is good evidence, not proof. A backend + // can carry the interface for minutes and still be unable to hold a + // large drawing: D3D12 on an integrated GPU runs out of room only + // once the whole scene uploads, well past the mark, and a run killed + // then used to be excused. The crash report says what actually + // happened, so ask it before excusing anything. + return match crash_report_blames_the_device(&text) { + true => named(), + false => Vec::new(), + }; } - text.lines() - .find_map(|l| l.strip_prefix("backend=")) - .unwrap_or("") - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() + named() +} + +/// Whether the run this sentinel belongs to died inside the graphics device. +/// +/// The sentinel records the process id it armed for, and crash reports are +/// named for the process that wrote them, so the two join exactly: this asks +/// about that run and not whichever report happens to be newest. +fn crash_report_blames_the_device(sentinel: &str) -> bool { + let Some(pid) = sentinel + .lines() + .find_map(|line| line.strip_prefix("pid=")) + .and_then(|value| value.trim().parse::().ok()) + else { + return false; + }; + let armed = sentinel + .lines() + .find_map(|line| line.strip_prefix("t=")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + crate::sys::crash_log::report_for_pid(pid, armed) + .is_some_and(|report| crate::sys::crash_log::is_device_failure(&report)) } /// Record that the armed backend has kept the app alive long enough to be @@ -1086,6 +1120,84 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Surviving the proof delay excuses a kill, but not a crash the + /// graphics device itself caused. D3D12 on an integrated GPU carries the + /// interface for minutes and only fails once a large drawing uploads, so + /// a run that died then has to keep costing the backend. + #[test] + fn a_device_crash_still_blames_the_backend_however_late_it_came() { + let _guard = SENTINEL_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("ocs-gpu-late-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + // SAFETY: single-threaded test mutating a process-local test hook. + unsafe { std::env::set_var(SENTINEL_DIR_ENV, &dir) }; + + let reports = crate::sys::crash_log::directory().expect("a config directory"); + let _ = std::fs::create_dir_all(&reports); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // A report older than the run, left by another process that once + // had this id, blames nobody. + let stale = reports.join(format!("crash-1-{}.log", std::process::id())); + let report = reports.join(format!("crash-{}-{}.log", now + 1, std::process::id())); + + arm_sentinel("dx12"); + mark_sentinel_survived(); + let _ = std::fs::remove_file(&report); + assert!( + crashed_backends().is_empty(), + "with no crash report, a survived run blames nobody" + ); + std::fs::write( + &stale, + crate::sys::crash_log::report_from("Out of Memory", "wgpu-29.0.4/src/lib.rs:1:1", 1), + ) + .unwrap(); + assert!( + crashed_backends().is_empty(), + "a report from before the run belongs to another process" + ); + let _ = std::fs::remove_file(&stale); + + // The run left a report, and it is the device's own failure. + std::fs::write( + &report, + crate::sys::crash_log::report_from( + "Error in Buffer::get_mapped_range: Validation Error", + "wgpu-29.0.4/src/backend/wgpu_core.rs:2253:18", + 1, + ), + ) + .unwrap(); + assert_eq!( + crashed_backends(), + vec!["dx12".to_string()], + "a device failure must cost the backend even past the proof delay" + ); + + // A crash that has nothing to do with graphics still costs nothing. + std::fs::write( + &report, + crate::sys::crash_log::report_from( + "index out of bounds: the len is 3 but the index is 7", + "src/app/commands/draw.rs:120:5", + 1, + ), + ) + .unwrap(); + assert!( + crashed_backends().is_empty(), + "an unrelated panic must not cost the user a working backend" + ); + + let _ = std::fs::remove_file(&report); + disarm_sentinel(); + unsafe { std::env::remove_var(SENTINEL_DIR_ENV) }; + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn force_hooks_drive_probes_without_spawning() { // SAFETY: single-threaded test mutating process-local test hooks. diff --git a/src/lib.rs b/src/lib.rs index b0b83da1b..af79d3b6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ #![allow(non_snake_case)] -#![recursion_limit = "256"] +#![recursion_limit = "512"] pub mod app; #[cfg(not(target_arch = "wasm32"))] diff --git a/src/mcp.rs b/src/mcp.rs index aa704f5a5..b3f9eeaeb 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -42,6 +42,8 @@ const READ_OPS: &[&str] = &[ "operation", "xdata_get", "audit", + "text_search", + "text_audit", ]; const EXECUTE_OPS: &[&str] = &[ "new", @@ -63,6 +65,7 @@ const EXECUTE_OPS: &[&str] = &[ "entities_create", "entities_delete", "entities_transform", + "text_replace", "block_define", "block_delete", "xdata_set", @@ -103,6 +106,7 @@ const BATCH_STEP_OPS: &[&str] = &[ "entities_create", "entities_delete", "entities_transform", + "text_replace", "block_define", "block_delete", "xdata_set", @@ -855,6 +859,18 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { r#"{"op":"save_verified","request_id":"deliver-1","path":"/absolute/output.dwg","target_version":"2018"}"#, ) } + "text_replace" => { + if request["pairs"].as_array().is_none() + && request["find"].as_str().is_none_or(str::is_empty) + { + missing( + "find or pairs", + r#"{"op":"text_replace","request_id":"tr1","find":"OLD","replace":"NEW"}"#, + ) + } else { + Ok(()) + } + } _ => Ok(()), } } @@ -1043,6 +1059,15 @@ fn execute_request_schema() -> Value { "camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."}, "selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."}, "cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]}, + "find":{"type":"string","description":"Search text to replace; supports DXF Unicode escapes and special characters."}, + "replace":{"type":"string","description":"Replacement text."}, + "pairs":{"type":"array","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]},"description":"Batch find/replace pairs executed in a single atomic undo transaction."}, + "match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search/replace."}, + "whole_word":{"type":"boolean","default":false,"description":"Match only whole words (bounded by non-alphanumeric characters)."}, + "ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."}, + "dry_run":{"type":"boolean","default":false,"description":"When true, simulates replacement without mutating entities or pushing undo."}, + "replace_all":{"type":"boolean","default":true,"description":"Replace all occurrences within matching entities (default true)."}, + "scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search/replace scope."}, "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."}, "block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."}, "linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."}, @@ -1138,6 +1163,7 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"entities_create"}},"required":["entities"]}, {"properties":{"op":{"const":"entities_delete"}},"required":["handles"]}, {"properties":{"op":{"const":"entities_transform"}},"required":["handles"]}, + {"properties":{"op":{"const":"text_replace"}}}, {"properties":{"op":{"const":"block_define"}},"required":["name","base","handles"]}, {"properties":{"op":{"const":"block_delete"}},"required":["name"]}, {"properties":{"op":{"const":"xdata_set"}},"required":["app","handles"]}, @@ -1199,7 +1225,7 @@ fn tool_definitions() -> Value { { "name":"ocs_read", "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},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"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."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"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}, + "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."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"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."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"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} }, @@ -1570,8 +1596,12 @@ mod tests { assert!(READ_OPS.contains(&"records")); assert!(READ_OPS.contains(&"record_schema")); assert!(READ_OPS.contains(&"audit")); + assert!(READ_OPS.contains(&"text_search")); + assert!(READ_OPS.contains(&"text_audit")); assert!(EXECUTE_OPS.contains(&"set_properties")); assert!(EXECUTE_OPS.contains(&"save_verified")); + assert!(EXECUTE_OPS.contains(&"text_replace")); + assert!(BATCH_STEP_OPS.contains(&"text_replace")); assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["properties"] ["target_version"]["enum"][0], diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index 5f835fef2..29a1ed58b 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -2724,6 +2724,224 @@ impl CadCommand for ArcCSLCommand { } +/// Solves an arc continuing from start point `s` with initial tangent `tangent` +/// that touches `target` tangentially. +/// +/// Returns `(center, radius, start_angle, end_angle, tangent_pt_on_target)`. +pub fn solve_tangent_continue( + s: DVec3, + tangent: DVec3, + target: TangentObject, + hit: DVec3, + flip: bool, + plane: WorkingPlane, +) -> Option<(DVec3, f64, f64, f64, DVec3)> { + let s_loc = plane.to_local(s); + let t_loc = plane.vector_to_local(tangent); + let hit_loc = plane.to_local(hit); + + let s2 = DVec2::new(s_loc.x, s_loc.y); + let t2 = DVec2::new(t_loc.x, t_loc.y).normalize_or_zero(); + let hit2 = DVec2::new(hit_loc.x, hit_loc.y); + + if t2.length_squared() < 1e-12 { + return None; + } + let n2 = DVec2::new(-t2.y, t2.x); + + match target { + TangentObject::Circle { center, radius } => { + let c_loc = plane.to_local(center); + let c2 = DVec2::new(c_loc.x, c_loc.y); + let r2 = radius.abs(); + if r2 < 1e-6 { + return None; + } + let d = s2 - c2; + let d_len_sq = d.length_squared(); + let d_dot_n = d.dot(n2); + + let mut candidates = Vec::new(); + for sigma in [1.0, -1.0] { + let denom = 2.0 * (d_dot_n - sigma * r2); + if denom.abs() > 1e-9 { + let r = (r2 * r2 - d_len_sq) / denom; + if r.abs() > 1e-4 && r.abs() < 1e8 { + let center2 = s2 + n2 * r; + let v = center2 - c2; + let dist = v.length(); + if dist > 1e-9 { + let u = v / dist; + let p1 = c2 + u * r2; + let p2 = c2 - u * r2; + let (best_p, err) = if ((p1 - center2).length() - r.abs()).abs() + <= ((p2 - center2).length() - r.abs()) + { + (p1, ((p1 - center2).length() - r.abs()).abs()) + } else { + (p2, ((p2 - center2).length() - r.abs()).abs()) + }; + if err < 1e-3 { + candidates.push(best_p); + } + } + } + } + } + + candidates + .into_iter() + .filter_map(|best_p| { + let tgt_world = plane.to_world(DVec3::new(best_p.x, best_p.y, s_loc.z)); + let res = arc_continue(s, tangent, tgt_world, flip, plane)?; + let score = (best_p - hit2).length_squared(); + Some((score, res, tgt_world)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, (c, r, sa, ea), tgt_world)| (c, r, sa, ea, tgt_world)) + } + + TangentObject::Line { p1, p2 } => { + let p1_loc = plane.to_local(p1); + let p2_loc = plane.to_local(p2); + let p1_2 = DVec2::new(p1_loc.x, p1_loc.y); + let p2_2 = DVec2::new(p2_loc.x, p2_loc.y); + let line_vec = p2_2 - p1_2; + let line_len = line_vec.length(); + if line_len < 1e-9 { + return None; + } + let v_line = line_vec / line_len; + let n_line = DVec2::new(-v_line.y, v_line.x); + let d0 = (s2 - p1_2).dot(n_line); + let k = n2.dot(n_line); + + let mut r_candidates = Vec::new(); + if (1.0 - k).abs() > 1e-9 { + r_candidates.push(d0 / (1.0 - k)); + } + if (1.0 + k).abs() > 1e-9 { + r_candidates.push(-d0 / (1.0 + k)); + } + if (1.0 - k * k).abs() <= 1e-9 && k.abs() > 1e-9 { + r_candidates.push(-d0 / (2.0 * k)); + } + + let mut candidates = Vec::new(); + for r in r_candidates { + if r.abs() > 1e-4 && r.abs() < 1e8 { + let center2 = s2 + n2 * r; + let p_tgt2 = center2 - n_line * ((center2 - p1_2).dot(n_line)); + let err = ((p_tgt2 - center2).length() - r.abs()).abs(); + if err < 1e-3 { + candidates.push(p_tgt2); + } + } + } + + candidates + .into_iter() + .filter_map(|best_p| { + let tgt_world = plane.to_world(DVec3::new(best_p.x, best_p.y, s_loc.z)); + let res = arc_continue(s, tangent, tgt_world, flip, plane)?; + let seg_t = (best_p - p1_2).dot(v_line); + let outside_dist = if seg_t < 0.0 { + -seg_t + } else if seg_t > line_len { + seg_t - line_len + } else { + 0.0 + }; + let score = + (best_p - hit2).length_squared() + outside_dist * outside_dist * 100.0; + Some((score, res, tgt_world)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, (c, r, sa, ea), tgt_world)| (c, r, sa, ea, tgt_world)) + } + + TangentObject::Ellipse { .. } => { + let ell = target_to_ellipse2d(target, plane)?; + + let eval_t = |t: f64| -> Option<(f64, DVec2, f64, DVec2)> { + let pt = ell.point_at(t); + let n_hat = ell.normal_at(t); + let delta = pt - s2; + let det = n2.y * n_hat.x - n2.x * n_hat.y; + if det.abs() < 1e-6 { + return None; + } + let r = (-delta.x * n_hat.y + delta.y * n_hat.x) / det; + let lambda = (n2.x * delta.y - n2.y * delta.x) / det; + let center = s2 + n2 * r; + let res = r.abs() - lambda.abs(); + Some((res, pt, r.abs(), center)) + }; + + let n_steps = 144; + let dt = std::f64::consts::TAU / (n_steps as f64); + let mut candidates = Vec::new(); + + for i in 0..n_steps { + let t_a = i as f64 * dt; + let t_b = (i + 1) as f64 * dt; + + let Some((res_a, _, _, _)) = eval_t(t_a) else { + continue; + }; + let Some((res_b, _, _, _)) = eval_t(t_b) else { + continue; + }; + + let n_hat_a = ell.normal_at(t_a); + let det_a = n2.y * n_hat_a.x - n2.x * n_hat_a.y; + let n_hat_b = ell.normal_at(t_b); + let det_b = n2.y * n_hat_b.x - n2.x * n_hat_b.y; + if det_a.signum() != det_b.signum() { + continue; + } + + if res_a.signum() != res_b.signum() { + let mut lo = t_a; + let mut hi = t_b; + let mut best_t = (lo + hi) * 0.5; + for _ in 0..16 { + let mid = (lo + hi) * 0.5; + let Some((res_m, _, _, _)) = eval_t(mid) else { + break; + }; + best_t = mid; + if res_m.abs() < 1e-8 { + break; + } + if res_m.signum() == res_a.signum() { + lo = mid; + } else { + hi = mid; + } + } + if let Some((res, pt, r, _center)) = eval_t(best_t) { + if res.abs() < 1e-3 && r.is_finite() && r > 1e-4 && r < 1e8 { + candidates.push(pt); + } + } + } + } + + candidates + .into_iter() + .filter_map(|best_p| { + let tgt_world = plane.to_world(DVec3::new(best_p.x, best_p.y, s_loc.z)); + let res = arc_continue(s, tangent, tgt_world, flip, plane)?; + let score = (best_p - hit2).length_squared(); + Some((score, res, tgt_world)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, (c, r, sa, ea), tgt_world)| (c, r, sa, ea, tgt_world)) + } + } +} + // ── Command 11: Continue (ARC_CONT) ────────────────────────────────────── pub struct ArcContCommand { s: DVec3, @@ -2731,6 +2949,7 @@ pub struct ArcContCommand { /// Live Ctrl state (set via `set_ctrl`): flips the arc to the other way. ctrl: bool, plane: WorkingPlane, + resolved_end: Option, } impl ArcContCommand { @@ -2740,6 +2959,7 @@ impl ArcContCommand { tangent, ctrl: false, plane: WorkingPlane::default(), + resolved_end: None, } } } @@ -2759,11 +2979,27 @@ impl CadCommand for ArcContCommand { self.ctrl = ctrl; } fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.on_point_with_tangent(pt, None).unwrap_or(CmdResult::NeedPoint) + } + fn on_point_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Option { + if let Some(target) = tangent { + if let Some((center, radius, sa, ea, tgt_pt)) = + solve_tangent_continue(self.s, self.tangent, target, pt, self.ctrl, self.plane) + { + self.resolved_end = Some(tgt_pt); + return Some(arc_result(center, radius, sa, ea, self.plane)); + } + } match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) { Some((center, radius, sa, ea)) => { - arc_result(center, radius, sa, ea, self.plane) + self.resolved_end = Some(pt); + Some(arc_result(center, radius, sa, ea, self.plane)) } - None => CmdResult::NeedPoint, + None => Some(CmdResult::NeedPoint), } } fn on_enter(&mut self) -> CmdResult { @@ -2773,10 +3009,29 @@ impl CadCommand for ArcContCommand { CmdResult::Cancel } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) { - Some((center, radius, sa, ea)) => arc_preview(center, radius, sa, ea, self.plane), - None => Some(line_wire(self.s, pt)), + self.on_preview_wires_with_tangent(pt, None).into_iter().next() + } + fn on_preview_wires_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Vec { + if let Some(target) = tangent { + if let Some((center, radius, sa, ea, _tgt_pt)) = + solve_tangent_continue(self.s, self.tangent, target, pt, self.ctrl, self.plane) + { + return arc_preview(center, radius, sa, ea, self.plane).into_iter().collect(); + } } + match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) { + Some((center, radius, sa, ea)) => { + arc_preview(center, radius, sa, ea, self.plane).into_iter().collect() + } + None => vec![line_wire(self.s, pt)], + } + } + fn resolved_anchor(&self) -> Option { + self.resolved_end } } @@ -3213,5 +3468,88 @@ mod acad_flow_tests { "Both endpoints must terminate tangentially on the respective ellipses! start: {arc_start:?}, end: {arc_end:?}" ); } + + #[test] + fn arc_cont_tangent_to_circle() { + let s = DVec3::new(0.0, 0.0, 0.0); + let tangent = DVec3::new(1.0, 0.0, 0.0); + let mut cmd = ArcContCommand::new(s, tangent); + + let circle = TangentObject::Circle { + center: DVec3::new(10.0, 10.0, 0.0), + radius: 4.0, + }; + + let hit = DVec3::new(10.0, 6.0, 0.0); + let result = cmd.on_point_with_tangent(hit, Some(circle)); + let arc = committed_arc(result.expect("arc cont command completed with tangent circle")); + + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + let start_pt = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let end_pt = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + assert!(start_pt.distance(s) < 1e-3 || end_pt.distance(s) < 1e-3); + + let resolved = cmd.resolved_anchor().expect("resolved anchor must exist"); + let circle_center = DVec3::new(10.0, 10.0, 0.0); + assert!((resolved.distance(circle_center) - 4.0).abs() < 1e-3, "resolved point must be on circle circumference"); + + let center_dist = center.distance(circle_center); + let is_ext_tangent = (center_dist - (arc.radius + 4.0)).abs() < 1e-3; + let is_int_tangent = (center_dist - (arc.radius - 4.0).abs()).abs() < 1e-3; + assert!(is_ext_tangent || is_int_tangent, "Arc must be tangent to circle: center_dist={center_dist}, arc.radius={}", arc.radius); + } + + #[test] + fn arc_cont_tangent_to_line() { + let s = DVec3::new(0.0, 0.0, 0.0); + let tangent = DVec3::new(1.0, 0.0, 0.0); + let mut cmd = ArcContCommand::new(s, tangent); + + let line = TangentObject::Line { + p1: DVec3::new(5.0, 10.0, 0.0), + p2: DVec3::new(15.0, 10.0, 0.0), + }; + + let hit = DVec3::new(10.0, 10.0, 0.0); + let result = cmd.on_point_with_tangent(hit, Some(line)); + let arc = committed_arc(result.expect("arc cont completed with tangent line")); + + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + assert!(((center.y - 10.0).abs() - arc.radius).abs() < 1e-3); + + let resolved = cmd.resolved_anchor().expect("resolved anchor must exist"); + assert!((resolved.y - 10.0).abs() < 1e-3, "resolved point must lie on the line"); + } + + #[test] + fn arc_cont_tangent_to_ellipse() { + let s = DVec3::new(0.0, 0.0, 0.0); + let tangent = DVec3::new(1.0, 0.0, 0.0); + let mut cmd = ArcContCommand::new(s, tangent); + + let ell = TangentObject::Ellipse { + center: DVec3::new(20.0, 10.0, 0.0), + major_axis: DVec3::new(10.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + + let hit = DVec3::new(15.0, 10.0, 0.0); + let result = cmd.on_point_with_tangent(hit, Some(ell)); + let _arc = committed_arc(result.expect("arc cont completed with tangent ellipse")); + + let resolved = cmd.resolved_anchor().expect("resolved anchor must exist"); + let dx = (resolved.x - 20.0) / 10.0; + let dy = (resolved.y - 10.0) / 5.0; + assert!((dx * dx + dy * dy - 1.0).abs() < 1e-3, "resolved point must lie on ellipse"); + } } diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index f72740a81..cba0c18f1 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -2613,8 +2613,11 @@ fn emit_wire( // composed rank the text sits at the bare insert level, an exact tie with // every sibling fill, and the later wipeout pass erases it (unselected // block text vanished under its wipeout; selecting won only because the - // xray pass ignores depth). - let local_depth = (lw.world_width > 0.0 || !lw.text_verts.is_empty()) + // xray pass ignores depth). Fill-only wires take the rank as well: an + // MTEXT background (a dimension's DIMTFILL box) left at the bare insert + // level sat in front of its own text whenever that text's rank was + // below zero. + let local_depth = (lw.world_width > 0.0 || lw.is_fill_only || !lw.text_verts.is_empty()) .then(|| d_range.0 + lw.local_rank * d_range.1); let plot_visible = ctx.plot_visible && lw.plot_visible @@ -2997,21 +3000,22 @@ fn transform_tangent( start_param, end_param, } => { - let c = t.apply(Vector3::new(center[0], center[1], center[2])); - let m = t.apply_rotation(Vector3::new(major_axis[0], major_axis[1], major_axis[2])); - let n = t.apply_rotation(Vector3::new(normal[0], normal[1], normal[2])); - let n_len = n.length(); - if n_len <= 1.0e-12 { - return None; - } - let n = n / n_len; + let (c, m, n, ratio, s, e) = crate::scene::view::transform::transform_ellipse_geometry( + Vector3::new(center[0], center[1], center[2]), + Vector3::new(major_axis[0], major_axis[1], major_axis[2]), + Vector3::new(normal[0], normal[1], normal[2]), + *minor_axis_ratio, + *start_param, + *end_param, + t, + )?; Some(TangentGeom::PlanarEllipse { center: [c.x, c.y, c.z], major_axis: [m.x, m.y, m.z], normal: [n.x, n.y, n.z], - minor_axis_ratio: *minor_axis_ratio, - start_param: *start_param, - end_param: *end_param, + minor_axis_ratio: ratio, + start_param: s, + end_param: e, }) } } @@ -3266,4 +3270,60 @@ mod compact_nested_tests { assert_eq!(wires.len(), 1); assert!(wires[0].render_instance.is_none()); } + + #[test] + fn test_transform_tangent_ellipse_arc_negative_scale_and_rotation() { + let ellipse_geom = TangentGeom::PlanarEllipse { + center: [10.0, 20.0, 0.0], + major_axis: [100.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.5, + start_param: std::f64::consts::FRAC_PI_4, + end_param: 3.0 * std::f64::consts::FRAC_PI_4, + }; + + // Negative scale (-1, -1, 1), negative rotation -45 deg, distant translation (7000, 5000, 0) + let t = Transform::from_scaling(Vector3::new(-1.0, -1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -std::f64::consts::FRAC_PI_4)) + .then(&Transform::from_translation(Vector3::new(7000.0, 5000.0, 0.0))); + + let transformed = transform_tangent(&ellipse_geom, &t).expect("transform_tangent should succeed"); + let TangentGeom::PlanarEllipse { + center: c, + major_axis: m, + normal: n, + minor_axis_ratio: ratio, + start_param: s, + end_param: _e, + } = transformed else { + panic!("Expected PlanarEllipse"); + }; + + // Center must be transformed + let expected_c = t.apply(Vector3::new(10.0, 20.0, 0.0)); + assert!((c[0] - expected_c.x).abs() < 1e-9); + assert!((c[1] - expected_c.y).abs() < 1e-9); + + // Major axis length must be 100, ratio 0.5 + let m_vec = Vector3::new(m[0], m[1], m[2]); + assert!((m_vec.length() - 100.0).abs() < 1e-9); + assert!((ratio - 0.5).abs() < 1e-9); + assert!((n[2] - 1.0).abs() < 1e-9); + + // Start and end points on the curve must match mathematical transformation of original start/end + let orig_start_pt = Vector3::new(10.0, 20.0, 0.0) + + Vector3::new(100.0, 0.0, 0.0) * (std::f64::consts::FRAC_PI_4).cos() + + Vector3::new(0.0, 50.0, 0.0) * (std::f64::consts::FRAC_PI_4).sin(); + let expected_start_pt = t.apply(orig_start_pt); + + let new_v = Vector3::new(n[0], n[1], n[2]).cross(&m_vec) * ratio; + let actual_start_pt = Vector3::new(c[0], c[1], c[2]) + + m_vec * s.cos() + + new_v * s.sin(); + + assert!( + (actual_start_pt - expected_start_pt).length() < 1e-9, + "Start point mismatch: expected {expected_start_pt:?}, got {actual_start_pt:?}" + ); + } } diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 2247fe1c7..7bed040dc 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -1321,6 +1321,10 @@ fn tessellate_entity_inner( set_wire_aabb(&mut wire, WireModel::UNBOUNDED_AABB); bases.push(wire); } + // DWF / DGN content: vector wires, sharp at every zoom. + if let Some(def) = crate::entities::underlay::definition(underlay, document) { + bases.extend(crate::scene::model::underlay_vector::display_wires(underlay, def, &h.value().to_string(), bg_color)); + } } // A hidden mask frame remains selectable and appears while selected, but diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 8d1e8b8bf..34837001e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3355,12 +3355,19 @@ impl Scene { retained_originals, &[], true, + false, ); } /// Refresh display and associations after a solved grip or exact history restore. pub(crate) fn bump_entities_after_parametric_solve(&mut self, changes: &[(Handle, ChangeKind)]) { - self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false); + self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false, false); + } + + /// Refresh display after a restyle that moved no geometry: nothing + /// associated to these entities is re-resolved or re-solved. + pub(crate) fn bump_entities_restyled(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false, true); } /// Apply a newly-created ordered relation while temporarily anchoring @@ -3379,6 +3386,7 @@ impl Scene { &[], fixed_refs, true, + false, ); } @@ -3396,6 +3404,7 @@ impl Scene { retained_originals, transformed_refs, true, + false, ); } @@ -3407,6 +3416,7 @@ impl Scene { retained_originals: &[(Handle, EntityType)], fixed_refs: &[parametric_constraints::ParametricRef], solve_parametric: bool, + appearance_only: bool, ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) @@ -3418,38 +3428,44 @@ impl Scene { self.associative_hatch_source_cache.borrow_mut().take(); } let mut changes = changes.to_vec(); - if self.associative_centers_possible(&changes) { - for change in self.refresh_associative_centerlines(&changes) { + // A restyle (layer on/off, colour, text or dimension style) moves no + // geometry, so nothing measured from it is re-resolved: an + // association that disagrees with its dimension would otherwise + // re-place the dimension on a layer toggle. + if !appearance_only { + if self.associative_centers_possible(&changes) { + for change in self.refresh_associative_centerlines(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } + for change in self.refresh_associative_center_marks(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } + } + for change in self.refresh_associative_dimensions(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } - for change in self.refresh_associative_center_marks(&changes) { + for change in self.refresh_associative_hatches(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } - } - for change in self.refresh_associative_dimensions(&changes) { - if !changes.iter().any(|(handle, _)| *handle == change.0) { - changes.push(change); - } - } - for change in self.refresh_associative_hatches(&changes) { - if !changes.iter().any(|(handle, _)| *handle == change.0) { - changes.push(change); - } - } - if solve_parametric && !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints_with_initial_policy( - &changes, - driven_refs, - retain_size, - retained_originals, - fixed_refs, - ) { - if !changes.iter().any(|(handle, _)| *handle == change.0) { - changes.push(change); + if solve_parametric && !self.parametric_constraints.is_empty() { + for change in self.refresh_parametric_constraints_with_initial_policy( + &changes, + driven_refs, + retain_size, + retained_originals, + fixed_refs, + ) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } } } } @@ -11470,7 +11486,7 @@ vis_index={:.1} visible_probe={:.1}", .into_iter() .map(|handle| (handle, ChangeKind::Modified)) .collect(); - self.bump_entities(&changes); + self.bump_entities_restyled(&changes); } pub fn invalidate_layer_dependencies(&mut self, names: &[String]) { diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index dfd6d1ea9..d486f146a 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -99,6 +99,59 @@ pub struct ImageModel { pub use_alpha: bool, } +/// The colour adjustments an underlay's content is drawn with over +/// the background: contrast, monochrome, the background adjustment and (DGN) +/// the fade. +pub(crate) fn underlay_adjust( + u: &codec::entities::Underlay, + def: &codec::entities::UnderlayDefinition, + background: [f32; 4], +) -> super::pdf_raster::PageAdjust { + use codec::entities::{UnderlayDisplayFlags, UnderlayType}; + use super::pdf_raster::PageAdjust; + let page = crate::entities::underlay::page_of(def); + // Dark means an HSL lightness under one half: pure blue counts as + // light, (0, 128, 0) as dark. + let bg_max = background[0].max(background[1]).max(background[2]); + let bg_min = background[0].min(background[1]).min(background[2]); + let bg_lum = (bg_max + bg_min) / 2.0; + let dgn = def.underlay_type == UnderlayType::Dgn; + // A DGN model is faded into the background before its contrast is + // applied (the reference's order), so it is drawn opaque. + let bg_rgb = [0, 1, 2].map(|i| (background[i].clamp(0.0, 1.0) * 255.0).round() as u8); + let fade_before_contrast = (dgn && u.fade > 0).then_some((bg_rgb, u.fade.min(100))); + PageAdjust { + contrast: u.contrast.min(100), + // Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are + // turned over for a dark background; a DGN model's from its + // own colours, over any background. + contrast_pivot: match def.underlay_type { + UnderlayType::Pdf => 500, + UnderlayType::Dwf + if u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && bg_lum < 0.5 => + { + 730 + } + UnderlayType::Dwf => 243, + UnderlayType::Dgn => super::underlay_vector::dgn_contrast_pivot( + &def.file_path, + page, + &super::pdf_layers::hidden_layers(u), + super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]), + fade_before_contrast, + ) + .unwrap_or(404), + }, + monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), + // A DGN model's colours follow the background through its + // colour table, whatever this setting. + adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) + && !dgn, + dark_background: bg_lum < 0.5, + fade_before_contrast, + } +} + impl ImageModel { /// Build an ImageModel from a DXF RasterImage entity. /// Returns `None` if the image file cannot be opened or decoded. @@ -197,7 +250,7 @@ impl ImageModel { world_per_pixel: Option, ) -> Option { use codec::entities::{UnderlayDisplayFlags, UnderlayType}; - use super::pdf_raster::{self, PageAdjust}; + use super::pdf_raster; if !u.flags.contains(UnderlayDisplayFlags::ON) { return None; } @@ -229,56 +282,11 @@ impl ImageModel { }; (source, raster) } - kind => { - let hidden = super::pdf_layers::hidden_layers(u); - // The adjusted-pixel memo keys on the source: one per set of - // hidden layers. - let source = if hidden.is_empty() { - def.file_path.clone() - } else { - format!("{}#{}", def.file_path, hidden.join("|")) - }; - let raster = super::underlay_vector::display_raster( - kind, - &def.file_path, - page, - &hidden, - screen_side.unwrap_or(super::underlay_vector::RASTER_SIDE), - )?; - (source, raster) - } + // DWF sheets and DGN models are drawn as vector wires + // (`underlay_vector::display_wires`). + _ => return None, }; - // Dark means an HSL lightness under one half: pure blue counts as - // light, (0, 128, 0) as dark. - let bg_max = background[0].max(background[1]).max(background[2]); - let bg_min = background[0].min(background[1]).min(background[2]); - let bg_lum = (bg_max + bg_min) / 2.0; - let pixels = pdf_raster::adjusted_pixels( - &source, - page, - &raster, - PageAdjust { - contrast: u.contrast.min(100), - // Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are - // turned over for a dark background; DGN 0.65. - contrast_pivot: match def.underlay_type { - UnderlayType::Pdf => 500, - UnderlayType::Dwf - if u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && bg_lum < 0.5 => - { - 730 - } - UnderlayType::Dwf => 243, - UnderlayType::Dgn => 650, - }, - monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), - adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND), - // A DGN model is drawn for a black background, so its - // colours turn over on a light one instead (white text stays - // light on a dark background, as the reference shows it). - dark_background: (bg_lum < 0.5) != (def.underlay_type == UnderlayType::Dgn), - }, - ); + let pixels = pdf_raster::adjusted_pixels(&source, page, &raster, underlay_adjust(u, def, background)); // The page rectangle in drawing units (1 unit per PDF inch, or per // DWF/DGN sheet unit), entity scale applied, from its lower-left diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index eab0bdd63..b613984d0 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -217,13 +217,18 @@ pub struct PageAdjust { /// towards `contrast_pivot` (hue and saturation kept). pub contrast: u8, /// The lightness contrast 0 leaves every colour at, in thousandths: - /// measured 500 for PDF, 243 for DWF and 650 for DGN underlays. + /// measured 500 for PDF and 243 for DWF; a DGN model's follows its + /// colours. pub contrast_pivot: u16, pub monochrome: bool, /// Lighten dark, unsaturated content on a dark background (black lines /// and text become white), keeping coloured content. pub adjust_for_background: bool, pub dark_background: bool, + /// A fade blended into the colours before the contrast (a DGN + /// model's): the background and the share, 0..=100, of the way each + /// colour is taken to it. + pub fade_before_contrast: Option<([u8; 3], u8)>, } type AdjustKey = (String, String, PageAdjust); @@ -233,10 +238,43 @@ fn adjusted_cache() -> &'static Mutex>>> { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +/// One colour (0..=1 channels) with the underlay's contrast / monochrome / +/// background adjustments applied. +pub fn adjust_rgb(rgb: [f32; 3], adjust: &PageAdjust) -> [f32; 3] { + let mut rgb = rgb; + if adjust.monochrome { + let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + rgb = [l; 3]; + } + if adjust.adjust_for_background && adjust.dark_background { + // On a dark background every colour's lightness is turned over + // (plus 20/255, capped at full), hue and saturation kept: black + // becomes white, dark red light pink. On a light background the + // colours stay as drawn. + let (h, s, l) = rgb_to_hsl(rgb); + rgb = hsl_to_rgb(h, s, (1.0 - l + 20.0 / 255.0).min(1.0)); + } + if let Some((bg, fade)) = adjust.fade_before_contrast { + let f = fade.min(100) as f32 / 100.0; + for (c, b) in rgb.iter_mut().zip(bg) { + *c += (b as f32 / 255.0 - *c) * f; + } + } + if adjust.contrast < 100 { + // Lightness is pulled towards the pivot, hue and saturation kept: + // contrast 0 leaves every colour at the pivot's lightness. + let (h, s, l) = rgb_to_hsl(rgb); + let k = adjust.contrast as f32 / 100.0; + let pivot = adjust.contrast_pivot as f32 / 1000.0; + rgb = hsl_to_rgb(h, s, pivot + (l - pivot) * k); + } + rgb +} + /// The display raster with the underlay's contrast / monochrome / background /// adjustments applied, memoised per source, page and adjustment. pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdjust) -> Arc> { - let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background; + let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background && adjust.fade_before_contrast.is_none(); if identity { return raster.pixels.clone(); } @@ -255,27 +293,7 @@ pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdj if px[3] == 0 { continue; } - let mut rgb = [px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0]; - if adjust.monochrome { - let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; - rgb = [l; 3]; - } - if adjust.adjust_for_background && adjust.dark_background { - // On a dark background every colour's lightness is turned over - // (plus 20/255, capped at full), hue and saturation kept: black - // becomes white, dark red light pink. On a light background the - // colours stay as drawn. - let (h, s, l) = rgb_to_hsl(rgb); - rgb = hsl_to_rgb(h, s, (1.0 - l + 20.0 / 255.0).min(1.0)); - } - if adjust.contrast < 100 { - // Lightness is pulled towards the pivot, hue and saturation kept: - // contrast 0 leaves every colour at the pivot's lightness. - let (h, s, l) = rgb_to_hsl(rgb); - let k = adjust.contrast as f32 / 100.0; - let pivot = adjust.contrast_pivot as f32 / 1000.0; - rgb = hsl_to_rgb(h, s, pivot + (l - pivot) * k); - } + let rgb = adjust_rgb([px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0], &adjust); for (dst, c) in px[..3].iter_mut().zip(rgb) { *dst = (c.clamp(0.0, 1.0) * 255.0).round() as u8; } diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index 8664c5eca..86e0f9fb7 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; -use super::model::{arc_cubics, bspline_points, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text}; +use super::model::{arc_cubics, bspline_points, dgn_color_table, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS}; /// A 32-bit integer stored as two little-endian words, high word first. fn int32(b: &[u8], at: usize) -> Option { @@ -76,36 +76,13 @@ fn elements(b: &[u8]) -> Vec<&[u8]> { out } -/// The file's colour table (element type 5 on level 1): the colour of -/// index i is at 41 + 3i. +/// The file's colour table (element type 5 on level 1): index i < 255 at +/// 41 + 3i, the background (255) at 38. fn color_table(elements: &[&[u8]]) -> Option> { - elements.iter().find_map(|e| { - (e[1] & 0x7f == 5 && e[0] & 0x3f == 1 && e.len() >= 41 + 765).then(|| { - (0..256) - .map(|i| { - let at = 41 + i * 3; - e.get(at..at + 3).map(|c| [c[0], c[1], c[2]]).unwrap_or([255, 255, 255]) - }) - .collect() - }) - }) -} - -/// A usable default: the first eight DGN colours, then greys. -// ponytail: The full DGN default table is not reproduced; the files -// the reference writes carry their own colour table. -fn default_colors() -> Vec<[u8; 3]> { - let base: [[u8; 3]; 8] = [ - [255, 255, 255], - [0, 0, 255], - [0, 255, 0], - [255, 0, 0], - [255, 255, 0], - [255, 0, 255], - [255, 127, 0], - [0, 255, 255], - ]; - (0..256).map(|i| if i < 8 { base[i] } else { [i as u8; 3] }).collect() + elements + .iter() + .find(|e| e[1] == 5 && e[0] & 0x3f == 1) + .and_then(|e| dgn_color_table(e, 41)) } /// Model names of a V7 file (it has one). @@ -432,7 +409,9 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { } let u = units(bytes)?; let all = elements(bytes); - let colors = color_table(&all).unwrap_or_else(default_colors); + let table = color_table(&all); + let table_colors = table.is_some(); + let colors = table.unwrap_or_else(|| DGN_DEFAULT_COLORS.to_vec()); let names = level_names(&all); let off = |e: &[u8]| { let l = level(e); @@ -463,7 +442,7 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { let raw = read_run(&top, &colors); let per_unit = u.uor_per_sub * u.sub_per_master; let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, -u.origin[0] / per_unit, -u.origin[1] / per_unit]; - let mut sheet = Sheet { sub_per_master: u.sub_per_master, ..Default::default() }; + let mut sheet = Sheet { sub_per_master: u.sub_per_master, table_colors, ..Default::default() }; place(&raw, &m, &defs, 0, &mut sheet); sheet.rect = paths_bounds(&sheet.paths).or_else(|| { let t = sheet.texts.first()?; diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index e1cc34d91..fb25c5c66 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::io::Read; -use super::model::{arc_cubics, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text}; +use super::model::{arc_cubics, dgn_color_table, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS}; // ── Compound file ─────────────────────────────────────────────────────────── @@ -188,7 +188,9 @@ fn elements(d: &[u8]) -> Vec<&[u8]> { // The stream can end inside its last element (its trailing words are // not stored); what is there is still read. let e = &d[at..(at + len).min(d.len())]; - out.push(e); + if !is_deleted(e) { + out.push(e); + } at += len; } out @@ -202,6 +204,12 @@ fn flags(e: &[u8]) -> u16 { u16::from_le_bytes([e[2], e[3]]) } +/// A deleted element (flag 0x80 of its second byte), kept in the stream but +/// no longer part of the file. +fn is_deleted(e: &[u8]) -> bool { + e[1] & 0x80 != 0 +} + /// A component of a complex element, cell or shared cell definition. fn is_component(e: &[u8]) -> bool { flags(e) & 0x4000 != 0 @@ -290,7 +298,7 @@ impl Styles { } } -/// Geometry in raw UORs, placed through a transform. +/// Plan geometry in UORs, and the shared cells it places. #[derive(Default, Clone)] struct Raw { paths: Vec, @@ -301,8 +309,31 @@ struct Raw { #[derive(Clone)] struct Instance { name: String, - m: [f64; 4], - origin: [f64; 2], + /// The definition's placement, with every enclosing one applied. + xf: Xf, +} + +/// A placement in UORs: x' = r[0]·(x, y, z, 1), y' = r[1]·…, z' = r[2]·…. +type Xf = [[f64; 4]; 3]; + +const IDENTITY: Xf = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + +fn xf_vector(m: &Xf, v: [f64; 3]) -> [f64; 3] { + m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2]) +} + +fn xf_point(m: &Xf, p: [f64; 3]) -> [f64; 3] { + m.map(|r| r[0] * p[0] + r[1] * p[1] + r[2] * p[2] + r[3]) +} + +fn xf_compose(outer: &Xf, inner: &Xf) -> Xf { + let mut out = [[0.0; 4]; 3]; + for (i, row) in out.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = (0..3).map(|k| outer[i][k] * inner[k][j]).sum::() + if j == 3 { outer[i][3] } else { 0.0 }; + } + } + out } /// A 3D element (flag 0x0800 of its properties word): three coordinates a @@ -311,17 +342,22 @@ fn is_3d(e: &[u8]) -> bool { u32_at(e, 40).is_some_and(|v| v & 0x0800 != 0) } -/// The in-plane x and y axes (projected onto the plan) of a 3D element's -/// orientation quaternion w, x, y, z stored at `at` (the rows of its -/// rotation matrix). -fn quat_axes(e: &[u8], at: usize) -> Option<([f64; 2], [f64; 2])> { +/// The in-plane x and y axes of a 3D element's orientation quaternion w, x, +/// y, z stored at `at` (the rows of its rotation matrix). +fn quat_axes(e: &[u8], at: usize) -> Option<([f64; 3], [f64; 3])> { let (w, x, y, z) = (f64_at(e, at)?, f64_at(e, at + 8)?, f64_at(e, at + 16)?, f64_at(e, at + 24)?); Some(( - [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z)], - [2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z)], + [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y)], + [2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x)], )) } +/// The in-plane axes of a 2D element turned by `rotation` (radians). +fn turned_axes(rotation: f64) -> ([f64; 3], [f64; 3]) { + let (c, s) = (rotation.cos(), rotation.sin()); + ([c, s, 0.0], [-s, c, 0.0]) +} + /// An elliptical arc on axes `u` and `v` (plan projections) as cubics. fn projected_arc(c: [f64; 2], a: f64, b: f64, u: [f64; 2], v: [f64; 2], start: f64, sweep: f64) -> Vec { let map = |p: [f64; 2]| [c[0] + a * p[0] * u[0] + b * p[1] * v[0], c[1] + a * p[0] * u[1] + b * p[1] * v[1]]; @@ -331,7 +367,8 @@ fn projected_arc(c: [f64; 2], a: f64, b: f64, u: [f64; 2], v: [f64; 2], start: f .collect() } -fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { +/// Reads a run of elements placed through `xf` into plan geometry. +fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { let mut raw = Raw::default(); // A complex shape or chain collects its components into one path. let mut complex: Option<(usize, PathBuilder, Option<([u8; 3], f64)>, Option<[u8; 3]>, bool)> = None; @@ -363,14 +400,25 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { raw.paths.push(path); } } + let plan = |p: [f64; 3]| { + let q = xf_point(xf, p); + [q[0], q[1]] + }; + let axis = |v: [f64; 3]| { + let q = xf_vector(xf, v); + [q[0], q[1]] + }; for e in run { if e.len() < 104 || styles.hidden.contains(&u32_at(e, 12).unwrap_or(0)) { continue; } let color = styles.color(e); let stroke = Some((color, -1.0)); - let p2 = |at: usize| -> Option<[f64; 2]> { Some([f64_at(e, at)?, f64_at(e, at + 8)?]) }; let d3 = is_3d(e); + // A point: x, y (and z in a 3D element). + let p3 = |at: usize| -> Option<[f64; 3]> { + Some([f64_at(e, at)?, f64_at(e, at + 8)?, if d3 { f64_at(e, at + 16)? } else { 0.0 }]) + }; match kind(e) { 12 | 14 => { let n = u32_at(e, 104).unwrap_or(0) as usize; @@ -380,14 +428,14 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { } } 3 => { - if let (Some(a), Some(b)) = (p2(104), p2(if d3 { 128 } else { 120 })) { - push(&mut raw, &mut complex, Path { subpaths: vec![SubPath { segments: vec![Segment::Line(a, b)], closed: false }], stroke, fill: None }); + if let (Some(a), Some(b)) = (p3(104), p3(if d3 { 128 } else { 120 })) { + push(&mut raw, &mut complex, Path { subpaths: vec![SubPath { segments: vec![Segment::Line(plan(a), plan(b))], closed: false }], stroke, fill: None }); } } 4 | 6 | 11 => { let n = u32_at(e, 104).unwrap_or(0) as usize; let stride = if d3 { 24 } else { 16 }; - let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| p2(112 + k * stride)).collect(); + let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| p3(112 + k * stride)).map(plan).collect(); if pts.len() >= 2 { let mut pb = PathBuilder::default(); pb.move_to(pts[0]); @@ -400,14 +448,16 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { push(&mut raw, &mut complex, Path { subpaths: pb.finish(), stroke, fill: None }); } } - 15 | 16 if d3 => { - // Axes, orientation and centre: ellipse a 104, b 112, + 15 | 16 => { + // Radii, orientation and centre. 3D: ellipse a 104, b 112, // quaternion 120, centre 152; arc start 104, sweep 112, a 120, - // b 128, quaternion 136, centre 168. - let arc = if kind(e) == 15 { - (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, quat_axes(e, 120)?, p2(152)?, 0.0, std::f64::consts::TAU)))() - } else { - (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, quat_axes(e, 136)?, p2(168)?, f64_at(e, 104)?, f64_at(e, 112)?)))() + // b 128, quaternion 136, centre 168. 2D: the orientation is an + // angle, ellipse centre 128, arc centre 144. + let arc = match (kind(e), d3) { + (15, true) => (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, quat_axes(e, 120)?, p3(152)?, 0.0, 0.0)))(), + (_, true) => (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, quat_axes(e, 136)?, p3(168)?, f64_at(e, 104)?, f64_at(e, 112)?)))(), + (15, false) => (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, turned_axes(f64_at(e, 120)?), p3(128)?, 0.0, 0.0)))(), + _ => (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, turned_axes(f64_at(e, 136)?), p3(144)?, f64_at(e, 104)?, f64_at(e, 112)?)))(), }; let Some((a, b, (u, v), c, start, sweep)) = arc else { continue }; let sweep = if sweep == 0.0 { std::f64::consts::TAU } else { sweep }; @@ -415,60 +465,52 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { push( &mut raw, &mut complex, - Path { subpaths: vec![SubPath { segments: projected_arc(c, a, b, u, v, start, sweep), closed }], stroke, fill: None }, + Path { subpaths: vec![SubPath { segments: projected_arc(plan(c), a, b, axis(u), axis(v), start, sweep), closed }], stroke, fill: None }, ); } - 15 | 16 => { - let arc = if kind(e) == 15 { - (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, f64_at(e, 120)?, p2(128)?, 0.0, std::f64::consts::TAU)))() + 17 => { + // 3D: width 112, height 120, quaternion 144, origin 176, the + // characters after the string marker. 2D: angle 144, origin + // 152, a byte count (with its 4-byte marker) at 110 and the + // characters from 174. + let placed = if d3 { + (|| Some((f64_at(e, 112)?, f64_at(e, 120)?, quat_axes(e, 144)?, p3(176)?, marked_string(e, 200)?)))() } else { - (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, f64_at(e, 136)?, p2(144)?, f64_at(e, 104)?, f64_at(e, 112)?)))() + (|| { + let count = u16::from_le_bytes(e.get(110..112)?.try_into().ok()?) as usize; + let chars = e.get(174..170 + count.max(4))?; + let text = String::from_utf8_lossy(chars).trim_end_matches(char::from(0)).to_string(); + Some((f64_at(e, 112)?, f64_at(e, 120)?, turned_axes(f64_at(e, 144)?), p3(152)?, text)) + })() }; - let Some((a, b, rot, c, start, sweep)) = arc else { continue }; - let pieces = arc_cubics(c, a, b, rot, start, if sweep == 0.0 { std::f64::consts::TAU } else { sweep }); - let closed = sweep.abs() >= std::f64::consts::TAU - 1e-9 || sweep == 0.0; - push( - &mut raw, - &mut complex, - Path { subpaths: vec![SubPath { segments: pieces.into_iter().map(|[a, b, c, d]| Segment::Cubic(a, b, c, d)).collect(), closed }], stroke, fill: None }, - ); - } - 17 if d3 => { - // Width 112, height 120, quaternion 144, origin 176; the - // characters follow the string marker. - let (Some(width), Some(height), Some((u, _)), Some(origin)) = (f64_at(e, 112), f64_at(e, 120), quat_axes(e, 144), p2(176)) else { + let Some((width, height, (u, v), origin, text)) = placed else { continue }; + // The text's axes as the plan shows them: its baseline sets the + // angle, and each axis's plan length scales its size. + let (pu, pv) = (axis(u), axis(v)); + let (su, sv) = (pu[0].hypot(pu[1]), pv[0].hypot(pv[1])); + if su < 1e-9 || sv < 1e-9 { continue; - }; - let Some(text) = marked_string(e, 200) else { continue }; - let (height, width) = (height * 6.0 / 1000.0, width * 6.0 / 1000.0); + } + let (height, width) = (height * 6.0 / 1000.0 * sv, width * 6.0 / 1000.0 * su); raw.texts.push(Text { text, - origin, + origin: plan(origin), height, width_factor: if height > 0.0 { width / height } else { 1.0 }, - rotation: u[1].atan2(u[0]), + rotation: pu[1].atan2(pu[0]), color, font: "txt".to_string(), }); } - 17 => { - let (Some(width), Some(height), Some(rotation), Some(origin)) = (f64_at(e, 112), f64_at(e, 120), f64_at(e, 144), p2(152)) else { - continue; - }; - // The text: a byte count (with its 4-byte marker) and the - // characters after the marker. - let count = u16::from_le_bytes([e[110], e[111]]) as usize; - let Some(chars) = e.get(174..170 + count.max(4)) else { continue }; - let text = String::from_utf8_lossy(chars).trim_end_matches(char::from(0)).to_string(); - let (height, width) = (height * 6.0 / 1000.0, width * 6.0 / 1000.0); - raw.texts.push(Text { text, origin, height, width_factor: if height > 0.0 { width / height } else { 1.0 }, rotation, color, font: "txt".to_string() }); - } 35 => { - let (Some(m0), Some(m1), Some(m3), Some(m4), Some(origin)) = (f64_at(e, 160), f64_at(e, 168), f64_at(e, 184), f64_at(e, 192), p2(232)) else { - continue; - }; + // Shared cell instance: a 3×3 matrix by rows from 160, the + // origin at 232 (z at 248 in 3D), the definition's name after + // the string marker. + let m: Option> = (0..9).map(|k| f64_at(e, 160 + k * 8)).collect(); + let (Some(m), Some(o)) = (m, p3(232)) else { continue }; let Some(name) = marked_string(e, 248) else { continue }; - raw.instances.push(Instance { name: name.to_ascii_uppercase(), m: [m0, m1, m3, m4], origin }); + let local: Xf = [[m[0], m[1], m[2], o[0]], [m[3], m[4], m[5], o[1]], [m[6], m[7], m[8], o[2]]]; + raw.instances.push(Instance { name: name.to_ascii_uppercase(), xf: xf_compose(xf, &local) }); } _ => {} } @@ -489,18 +531,9 @@ fn apply(m: &Affine, p: [f64; 2]) -> [f64; 2] { [m[0] * p[0] + m[1] * p[1] + m[4], m[2] * p[0] + m[3] * p[1] + m[5]] } -fn compose(o: &Affine, i: &Affine) -> Affine { - [ - o[0] * i[0] + o[1] * i[2], - o[0] * i[1] + o[1] * i[3], - o[2] * i[0] + o[3] * i[2], - o[2] * i[1] + o[3] * i[3], - o[0] * i[4] + o[1] * i[5] + o[4], - o[2] * i[4] + o[3] * i[5] + o[5], - ] -} - -fn place(raw: &Raw, m: &Affine, defs: &HashMap, depth: usize, out: &mut Sheet) { +/// Places plan geometry through the sheet mapping `m`, and the shared cells +/// it instances (each definition read through its own placement). +fn place(raw: &Raw, m: &Affine, defs: &HashMap>, styles: &Styles, depth: usize, out: &mut Sheet) { let map = |p: [f64; 2]| apply(m, p); for path in &raw.paths { out.paths.push(Path { @@ -533,8 +566,7 @@ fn place(raw: &Raw, m: &Affine, defs: &HashMap, depth: usize, out: } for inst in &raw.instances { let Some(def) = defs.get(&inst.name) else { continue }; - let local: Affine = [inst.m[0], inst.m[1], inst.m[2], inst.m[3], inst.origin[0], inst.origin[1]]; - place(def, &compose(m, &local), defs, depth + 1, out); + place(&read_run(def, styles, &inst.xf), m, defs, styles, depth + 1, out); } } @@ -627,11 +659,10 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { let (_, graphics, header) = all.iter().find(|m| m.0.eq_ignore_ascii_case(name)).or_else(|| all.first())?; let non_model = non_model(&cfb); let nm = elements(&non_model); - let colors = nm - .iter() - .find(|e| kind(e) == 5 && e.len() >= 37 + 768) - .map(|e| (0..256).map(|i| [e[37 + i * 3], e[38 + i * 3], e[39 + i * 3]]).collect()) - .unwrap_or_else(|| vec![[255, 255, 255]; 256]); + // Colours 0-254 from byte 37, the background (255) opening the table. + let color_table = nm.iter().find(|e| kind(e) == 5).and_then(|e| dgn_color_table(e, 37)); + let table_colors = color_table.is_some(); + let colors = color_table.unwrap_or_else(|| DGN_DEFAULT_COLORS.to_vec()); let table = level_table(&nm); let levels = table.iter().map(|l| (l.0, l.2)).collect(); let hidden = table @@ -641,7 +672,7 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { .collect(); let styles = Styles { colors, levels, hidden, extended: extended_colors(&cfb) }; // Shared cell definitions: a type-34 element and its components. - let mut defs = HashMap::new(); + let mut defs: HashMap> = HashMap::new(); let mut i = 0; while i < nm.len() { if kind(nm[i]) == 34 && !is_component(nm[i]) { @@ -650,17 +681,46 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { while j < nm.len() && is_component(nm[j]) { j += 1; } - defs.insert(name, read_run(&nm[i + 1..j], &styles)); + defs.insert(name, nm[i + 1..j].to_vec()); i = j; } else { i += 1; } } - let raw = read_run(&elements(graphics), &styles); + let graphics = elements(graphics); + let raw = read_run(&graphics, &styles, &IDENTITY); let (per_unit, sub_uor) = units(header); - let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, 0.0, 0.0]; - let mut sheet = Sheet { sub_per_master: per_unit / sub_uor, ..Default::default() }; - place(&raw, &m, &defs, 0, &mut sheet); - sheet.rect = paths_bounds(&sheet.paths)?; + let origin = global_origin(header); + let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, -origin[0] / per_unit, -origin[1] / per_unit]; + let mut sheet = Sheet { sub_per_master: per_unit / sub_uor, table_colors, ..Default::default() }; + place(&raw, &m, &defs, &styles, 0, &mut sheet); + // The model's extent is its elements' stored ranges (text by its full + // box), as the reference sizes a model. + sheet.rect = match stored_range(&graphics) { + Some([x0, y0, x1, y1]) => { + let (lo, hi) = (apply(&m, [x0, y0]), apply(&m, [x1, y1])); + [lo[0], lo[1], hi[0], hi[1]] + } + None => paths_bounds(&sheet.paths)?, + }; Some(sheet) } + +/// The model's global origin (UORs; the design plane point drawn at the +/// model's 0,0). +fn global_origin(header: &[u8]) -> [f64; 2] { + let get = |at: usize| f64_at(header, at).filter(|v| v.is_finite()).unwrap_or(0.0); + [get(4212), get(4220)] +} + +/// The union of the top-level elements' ranges (UORs): each graphic element +/// stores its low corner at 56 and its size at 80, as 64-bit integers. +fn stored_range(graphics: &[&[u8]]) -> Option<[f64; 4]> { + let i64_at = |e: &[u8], at: usize| Some(i64::from_le_bytes(e.get(at..at + 8)?.try_into().ok()?) as f64); + let mut r = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for e in graphics.iter().filter(|e| e.len() >= 104 && !is_component(e)) { + let (Some(x), Some(y), Some(w), Some(h)) = (i64_at(e, 56), i64_at(e, 64), i64_at(e, 80), i64_at(e, 88)) else { continue }; + r = [r[0].min(x), r[1].min(y), r[2].max(x + w), r[3].max(y + h)]; + } + (r[0] <= r[2]).then_some(r) +} diff --git a/src/scene/model/underlay_vector/dwf.rs b/src/scene/model/underlay_vector/dwf.rs index 52e26b16d..e654c0721 100644 --- a/src/scene/model/underlay_vector/dwf.rs +++ b/src/scene/model/underlay_vector/dwf.rs @@ -180,7 +180,7 @@ pub fn sheet(bytes: &[u8], name: &str) -> Option { xps::read(data, w2x.map(|v| v.as_slice()), entry, &family)? }; let rect = view.or_else(|| paths_bounds(&paths))?; - Some(Sheet { rect, paths, texts, sub_per_master: 1.0 }) + Some(Sheet { rect, paths, texts, sub_per_master: 1.0, table_colors: false, element_colors: Vec::new() }) } /// An XPS obfuscated font (.odttf): its first 32 bytes are XORed with the diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index 09809956f..87daea5bf 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -11,6 +11,9 @@ mod dgn7; mod dgn8; mod dwf; mod model; +mod wires; + +pub(crate) use wires::display_wires; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; @@ -93,10 +96,11 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin UnderlayType::Dgn => dgn8::model(&bytes, item, hidden).unwrap_or_default(), UnderlayType::Pdf => return None, }; - // A DGN model's extent includes its text: each text's box runs its - // length along the baseline and 1.5 × its height above it (as the - // reference measures a model). A DWF sheet keeps its plotted view. - if kind == UnderlayType::Dgn { + // A V7 model's extent includes its text: each text's box runs its + // length along the baseline and its height above it (as the + // reference measures a model). A V8 model has its stored element + // ranges; a DWF sheet keeps its plotted view. + if kind == UnderlayType::Dgn && dgn7::is_v7(&bytes) { let mut r = sheet.rect; for t in &sheet.texts { let (strokes, _) = crate::scene::text::lff::tessellate_text_ex( @@ -110,13 +114,14 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin ); let length = strokes.iter().flatten().map(|p| p[0] as f64).fold(0.0, f64::max); let (c, s) = (t.rotation.cos(), t.rotation.sin()); - for [x, y] in [[0.0, 0.0], [length, 0.0], [length, 1.5 * t.height], [0.0, 1.5 * t.height]] { + for [x, y] in [[0.0, 0.0], [length, 0.0], [length, t.height], [0.0, t.height]] { let p = [t.origin[0] + x * c - y * s, t.origin[1] + x * s + y * c]; r = [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]; } } sheet.rect = r; } + sheet.element_colors = element_colors(&sheet); outline_texts(&mut sheet); if !hidden.is_empty() { sheet.rect = sheet_without(kind, path, item, &[])?.rect; @@ -128,6 +133,45 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin value } +/// The colour of each path and text of the sheet (a path's fill before +/// its stroke). +fn element_colors(sheet: &Sheet) -> Vec<[u8; 3]> { + sheet + .paths + .iter() + .filter_map(|p| p.fill.or(p.stroke.map(|s| s.0))) + .chain(sheet.texts.iter().map(|t| t.color)) + .collect() +} + +/// The lightness a DGN model's contrast pulls its colours towards, in +/// thousandths. The reference takes it from the model's own colours as +/// shown (over `backdrop`, faded by `fade` into the background): about +/// 0.788 × their mean lightness, each element counted once (0.788 for an +/// all-white model, 0.416 for mid grey, 0.09 for dark red). +pub fn dgn_contrast_pivot( + path: &str, + item: &str, + hidden: &[String], + backdrop: Backdrop, + fade: Option<([u8; 3], u8)>, +) -> Option { + let sheet = sheet_without(UnderlayType::Dgn, path, item, hidden)?; + if sheet.element_colors.is_empty() { + return None; + } + let lightness = |c: [u8; 3]| { + let c = if sheet.table_colors { model::dgn_table_color(c, backdrop.max_channel, backdrop.light) } else { c }; + let c = match fade { + Some((bg, f)) => [0, 1, 2].map(|i| c[i] as f64 + (bg[i] as f64 - c[i] as f64) * f.min(100) as f64 / 100.0), + None => c.map(|v| v as f64), + }; + (c[0].max(c[1]).max(c[2]) + c[0].min(c[1]).min(c[2])) / 510.0 + }; + let mean = sheet.element_colors.iter().map(|&c| lightness(c)).sum::() / sheet.element_colors.len() as f64; + Some((0.788 * mean * 1000.0).round() as u16) +} + /// Text as strokes (and fills for outline fonts), in the text's colour. fn outline_texts(sheet: &mut Sheet) { for t in std::mem::take(&mut sheet.texts) { @@ -165,21 +209,64 @@ fn outline_texts(sheet: &mut Sheet) { /// Longest side of a sheet's full-size display raster, pixels. pub const RASTER_SIDE: f64 = 3072.0; +/// The background a sheet is shown over, as far as its colours depend on +/// it: the largest channel (in the steps a DGN colour table changes at) and +/// whether the background is light. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Backdrop { + pub max_channel: u8, + pub light: bool, +} + +impl Backdrop { + pub fn of(rgb: [f32; 3]) -> Self { + let max = rgb[0].max(rgb[1]).max(rgb[2]); + let min = rgb[0].min(rgb[1]).min(rgb[2]); + let top = (max.clamp(0.0, 1.0) * 255.0).round() as u8; + Backdrop { + max_channel: match top { + 0..=29 => 0, + 30..=31 => 30, + _ => 32, + }, + // Light means an HSL lightness of one half or more. + light: (max + min) / 2.0 >= 0.5, + } + } +} + /// The sheet drawn on a transparent background with `side` pixels on its /// longest side (at most [`RASTER_SIDE`]), straight alpha, rows from the -/// top, memoised per size. -pub fn display_raster(kind: UnderlayType, path: &str, item: &str, hidden: &[String], side: f64) -> Option> { +/// top, memoised per size and backdrop. +pub fn display_raster( + kind: UnderlayType, + path: &str, + item: &str, + hidden: &[String], + side: f64, + backdrop: Backdrop, +) -> Option> { let side = side.clamp(16.0, RASTER_SIDE).round(); - let key = (path.to_string(), format!("{}@{side}", layered_item(item, hidden))); + let key = ( + path.to_string(), + format!("{}@{side}~{}{}", layered_item(item, hidden), backdrop.max_channel, backdrop.light), + ); if let Some(hit) = raster_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return hit.clone(); } - let value = sheet_without(kind, path, item, hidden).and_then(|s| rasterize(&s, side)).map(Arc::new); + let value = sheet_without(kind, path, item, hidden).and_then(|s| rasterize(&s, side, backdrop)).map(Arc::new); raster_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key, value.clone()); value } -fn rasterize(sheet: &Sheet, side: f64) -> Option { +fn rasterize(sheet: &Sheet, side: f64, backdrop: Backdrop) -> Option { + let shown = |c: [u8; 3]| { + if sheet.table_colors { + model::dgn_table_color(c, backdrop.max_channel, backdrop.light) + } else { + c + } + }; let [x0, y0, x1, y1] = sheet.rect; let (w, h) = (x1 - x0, y1 - y0); if !(w > 0.0 && h > 0.0) { @@ -188,7 +275,10 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option { let scale = side / w.max(h); let (pw, ph) = ((w * scale).ceil().max(1.0) as u32, (h * scale).ceil().max(1.0) as u32); let mut pixmap = tiny_skia::Pixmap::new(pw, ph)?; - let to_px = |p: [f64; 2]| (((p[0] - x0) * scale) as f32, ((y1 - p[1]) * scale) as f32); + // The rectangle's edges fall on the centres of the outer pixels, so a + // line along the edge of the extent (every extent has some) is drawn. + let (sx, sy) = ((pw.max(2) - 1) as f64 / w, (ph.max(2) - 1) as f64 / h); + let to_px = |p: [f64; 2]| (((p[0] - x0) * sx + 0.5) as f32, ((y1 - p[1]) * sy + 0.5) as f32); for path in &sheet.paths { let mut pb = tiny_skia::PathBuilder::new(); for sp in &path.subpaths { @@ -212,28 +302,43 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option { } } let Some(p) = pb.finish() else { continue }; - if let Some(c) = path.fill { + if let Some(c) = path.fill.map(shown) { let mut paint = tiny_skia::Paint::default(); paint.set_color_rgba8(c[0], c[1], c[2], 255); // Fills that share an edge (triangle strips) would show a seam. paint.anti_alias = false; pixmap.fill_path(&p, &paint, tiny_skia::FillRule::Winding, tiny_skia::Transform::identity(), None); } - if let Some((c, width)) = path.stroke { + if let Some((c, width)) = path.stroke.map(|(c, w)| (shown(c), w)) { let mut paint = tiny_skia::Paint::default(); paint.set_color_rgba8(c[0], c[1], c[2], 255); // A negative width is in pixels (DGN line weights). let px = if width < 0.0 { (-width) as f32 } else { ((width * scale) as f32).max(1.0) }; - // Hairlines are drawn solid, one pixel wide, as the reference - // draws them; anti-aliasing would spread them to half intensity. - paint.anti_alias = px > 1.5; let stroke = tiny_skia::Stroke { width: px, line_cap: tiny_skia::LineCap::Round, line_join: tiny_skia::LineJoin::Round, ..Default::default() }; - pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + if px > 1.5 { + paint.anti_alias = true; + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } else { + // Hairlines: horizontal and vertical pieces solid, one pixel + // wide, as the reference draws them (anti-aliasing would + // spread them to half intensity); slanted pieces and curves + // anti-aliased, since their pixel steps break up once the + // raster is scaled to the screen. + let (straight, curved) = hairline_parts(path, &to_px); + paint.anti_alias = false; + if let Some(p) = straight { + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } + paint.anti_alias = true; + if let Some(p) = curved { + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } + } } } let mut pixels = pixmap.take(); @@ -248,6 +353,57 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option { Some(PdfPage { pixels: Arc::new(pixels), width: pw, height: ph, dpi: 0.0 }) } +/// A path's horizontal and vertical segments, and its slanted and curved +/// ones, each as a pixel path. +fn hairline_parts( + path: &Path, + to_px: &dyn Fn([f64; 2]) -> (f32, f32), +) -> (Option, Option) { + let (mut straight, mut curved) = (tiny_skia::PathBuilder::new(), tiny_skia::PathBuilder::new()); + // Consecutive segments of one kind stay one run, so their joins are + // drawn once. + let (mut straight_at, mut curved_at) = (None, None); + for sp in &path.subpaths { + for seg in &sp.segments { + let a = to_px(seg.start()); + match seg { + Segment::Line(_, b) if along_axis(a, to_px(*b)) => { + let b = to_px(*b); + if straight_at != Some(a) { + straight.move_to(a.0, a.1); + } + straight.line_to(b.0, b.1); + straight_at = Some(b); + } + Segment::Line(_, b) => { + let b = to_px(*b); + if curved_at != Some(a) { + curved.move_to(a.0, a.1); + } + curved.line_to(b.0, b.1); + curved_at = Some(b); + } + Segment::Cubic(_, c1, c2, b) => { + let (c1, c2, b) = (to_px(*c1), to_px(*c2), to_px(*b)); + if curved_at != Some(a) { + curved.move_to(a.0, a.1); + } + curved.cubic_to(c1.0, c1.1, c2.0, c2.1, b.0, b.1); + curved_at = Some(b); + } + } + } + straight_at = None; + curved_at = None; + } + (straight.finish(), curved.finish()) +} + +/// A pixel segment that runs horizontally or vertically. +fn along_axis(a: (f32, f32), b: (f32, f32)) -> bool { + (b.0 - a.0).abs() < 0.01 || (b.1 - a.1).abs() < 0.01 +} + fn vector_cache() -> &'static Mutex>>> { static CACHE: OnceLock>>>> = OnceLock::new(); CACHE.get_or_init(Default::default) diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs index 90e7dd28a..a4bcdae1a 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -39,6 +39,47 @@ pub struct Sheet { pub texts: Vec, /// DGN: sub units per master unit (the scale a Sub conversion offers). pub sub_per_master: f64, + /// DGN: the colours come from the file's own colour table, which the + /// reference shows adapted to the background ([`dgn_table_color`]). + pub table_colors: bool, + /// The colour of each path and text the sheet draws, one per element. + pub element_colors: Vec<[u8; 3]>, +} + +/// How a colour from a DGN file's colour table shows over a background +/// (its largest channel and whether it is light), as the reference draws +/// it: a near-white colour (61 or less from white, channels summed) is white, +/// or black on a light background; over a background with a channel of 32 or +/// more, colours whose largest channel is 76, 127, 153 or 204 drop a shade +/// (to 38, 76, 127 or 165, hue kept) and the greys 91, 132, 173 and 214 drop +/// to 45, 91, 137 and 183 — grey 51 to black from a channel of 30. +pub fn dgn_table_color(rgb: [u8; 3], bg_max: u8, light: bool) -> [u8; 3] { + if rgb.iter().map(|&c| 255 - c as u32).sum::() <= 61 { + return if light { [0; 3] } else { [255; 3] }; + } + if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + let v = match rgb[0] { + 51 if bg_max >= 30 => 0, + 91 if bg_max >= 32 => 45, + 132 if bg_max >= 32 => 91, + 173 if bg_max >= 32 => 137, + 214 if bg_max >= 32 => 183, + v => v, + }; + return [v; 3]; + } + let top = rgb.into_iter().max().unwrap_or(0); + let to = match top { + 76 => 38, + 127 => 76, + 153 => 127, + 204 => 165, + _ => return rgb, + }; + if bg_max < 32 { + return rgb; + } + rgb.map(|c| ((c as u32 * to + top as u32 / 2) / top as u32) as u8) } /// Builds paths from pen moves. @@ -131,7 +172,8 @@ pub fn arc_cubics( out } -/// Bounds of the paths, when there are any. +/// Tight bounds of the paths (curves by their extreme points, not their +/// control points), when there are any. pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; let mut add = |p: [f64; 2]| { @@ -147,9 +189,10 @@ pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { } Segment::Cubic(a, c1, c2, c) => { add(*a); - add(*c1); - add(*c2); add(*c); + for t in cubic_extrema(*a, *c1, *c2, *c) { + add(cubic_at(*a, *c1, *c2, *c, t)); + } } } } @@ -158,6 +201,85 @@ pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { (b[0] <= b[2]).then_some(b) } +fn cubic_at(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]] +} + +/// Parameters in (0, 1) where a cubic turns in x or in y. +fn cubic_extrema(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2]) -> Vec { + let mut out = Vec::new(); + for i in 0..2 { + // The derivative's quadratic a·t² + b·t + c. + let a = 3.0 * (-p0[i] + 3.0 * p1[i] - 3.0 * p2[i] + p3[i]); + let b = 6.0 * (p0[i] - 2.0 * p1[i] + p2[i]); + let c = 3.0 * (p1[i] - p0[i]); + if a.abs() < 1e-12 { + if b.abs() > 1e-12 { + out.push(-c / b); + } + continue; + } + let disc = b * b - 4.0 * a * c; + if disc >= 0.0 { + let r = disc.sqrt(); + out.push((-b + r) / (2.0 * a)); + out.push((-b - r) / (2.0 * a)); + } + } + out.retain(|t| *t > 0.0 && *t < 1.0); + out +} + +/// The colour table a DGN file without one is drawn with (index 255, the +/// background entry, black). +pub const DGN_DEFAULT_COLORS: [[u8; 3]; 256] = [ + [255, 255, 255], [0, 0, 255], [0, 255, 0], [255, 0, 0], [255, 255, 0], [255, 0, 255], [255, 127, 0], [0, 255, 255], + [64, 64, 64], [192, 192, 192], [254, 0, 96], [160, 224, 0], [0, 254, 160], [128, 0, 160], [176, 176, 176], [0, 240, 240], + [240, 240, 240], [0, 0, 240], [0, 240, 0], [240, 0, 0], [240, 240, 0], [240, 0, 240], [240, 122, 0], [0, 240, 240], + [240, 240, 240], [0, 0, 240], [0, 240, 0], [240, 0, 0], [240, 240, 0], [240, 0, 240], [240, 122, 0], [0, 225, 225], + [225, 225, 225], [0, 0, 225], [0, 225, 0], [225, 0, 0], [225, 225, 0], [225, 0, 225], [225, 117, 0], [0, 225, 225], + [225, 225, 225], [0, 0, 225], [0, 225, 0], [225, 0, 0], [225, 225, 0], [225, 0, 225], [225, 117, 0], [0, 210, 210], + [210, 210, 210], [0, 0, 210], [0, 210, 0], [210, 0, 0], [210, 210, 0], [210, 0, 210], [210, 112, 0], [0, 210, 210], + [210, 210, 210], [0, 0, 210], [0, 210, 0], [210, 0, 0], [210, 210, 0], [210, 0, 210], [210, 112, 0], [0, 195, 195], + [195, 195, 195], [0, 0, 195], [0, 195, 0], [195, 0, 0], [195, 195, 0], [195, 0, 195], [195, 107, 0], [0, 195, 195], + [195, 195, 195], [0, 0, 195], [0, 195, 0], [195, 0, 0], [195, 195, 0], [195, 0, 195], [195, 107, 0], [0, 180, 180], + [180, 180, 180], [0, 0, 180], [0, 180, 0], [180, 0, 0], [180, 180, 0], [180, 0, 180], [180, 102, 0], [0, 180, 180], + [180, 180, 180], [0, 0, 180], [0, 180, 0], [180, 0, 0], [180, 180, 0], [180, 0, 180], [180, 102, 0], [0, 165, 165], + [165, 165, 165], [0, 0, 165], [0, 165, 0], [165, 0, 0], [165, 165, 0], [165, 0, 165], [165, 97, 0], [0, 165, 165], + [165, 165, 165], [0, 0, 165], [0, 165, 0], [165, 0, 0], [165, 165, 0], [165, 0, 165], [165, 97, 0], [0, 150, 150], + [150, 150, 150], [0, 0, 150], [0, 150, 0], [150, 0, 0], [150, 150, 0], [150, 0, 150], [150, 92, 0], [0, 150, 150], + [150, 150, 150], [0, 0, 150], [0, 150, 0], [150, 0, 0], [150, 150, 0], [150, 0, 150], [150, 92, 0], [0, 135, 135], + [135, 135, 135], [0, 0, 135], [0, 135, 0], [135, 0, 0], [135, 135, 0], [135, 0, 135], [135, 87, 0], [0, 135, 135], + [135, 135, 135], [0, 0, 135], [0, 135, 0], [135, 0, 0], [135, 135, 0], [135, 0, 135], [135, 87, 0], [0, 120, 120], + [120, 120, 120], [0, 0, 120], [0, 120, 0], [120, 0, 0], [120, 120, 0], [120, 0, 120], [120, 82, 0], [0, 120, 120], + [120, 120, 120], [0, 0, 120], [0, 120, 0], [120, 0, 0], [120, 120, 0], [120, 0, 120], [120, 82, 0], [0, 105, 105], + [105, 105, 105], [0, 0, 105], [0, 105, 0], [105, 0, 0], [105, 105, 0], [105, 0, 105], [105, 77, 0], [0, 105, 105], + [105, 105, 105], [0, 0, 105], [0, 105, 0], [105, 0, 0], [105, 105, 0], [105, 0, 105], [105, 77, 0], [0, 90, 90], + [90, 90, 90], [0, 0, 90], [0, 90, 0], [90, 0, 0], [90, 90, 0], [90, 0, 90], [90, 72, 0], [0, 90, 90], + [90, 90, 90], [0, 0, 90], [0, 90, 0], [90, 0, 0], [90, 90, 0], [90, 0, 90], [90, 72, 0], [0, 75, 75], + [75, 75, 75], [0, 0, 75], [0, 75, 0], [75, 0, 0], [75, 75, 0], [75, 0, 75], [75, 67, 0], [0, 75, 75], + [75, 75, 75], [0, 0, 75], [0, 75, 0], [75, 0, 0], [75, 75, 0], [75, 0, 75], [75, 67, 0], [0, 60, 60], + [60, 60, 60], [0, 0, 60], [0, 60, 0], [60, 0, 0], [60, 60, 0], [60, 0, 60], [60, 62, 0], [0, 60, 60], + [60, 60, 60], [0, 0, 60], [0, 60, 0], [60, 0, 0], [60, 60, 0], [60, 0, 60], [60, 62, 0], [0, 45, 45], + [45, 45, 45], [0, 0, 45], [0, 45, 0], [45, 0, 0], [45, 45, 0], [45, 0, 45], [45, 57, 0], [0, 45, 45], + [45, 45, 45], [0, 0, 45], [0, 45, 0], [45, 0, 0], [45, 45, 0], [45, 0, 45], [45, 57, 0], [0, 30, 30], + [30, 30, 30], [0, 0, 30], [0, 30, 0], [30, 0, 0], [30, 30, 0], [30, 0, 30], [30, 52, 0], [0, 30, 30], + [30, 30, 30], [0, 0, 30], [0, 30, 0], [30, 0, 0], [30, 30, 0], [30, 0, 30], [192, 192, 192], [0, 0, 0], +]; + +/// A colour table element's 256 colours: entries 0-254 from `first`, and +/// entry 255 (the background) from the three bytes that open the table at +/// `first - 3`. +pub fn dgn_color_table(e: &[u8], first: usize) -> Option> { + let bg = e.get(first - 3..first)?; + let body = e.get(first..first + 255 * 3)?; + let mut out: Vec<[u8; 3]> = body.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + out.push([bg[0], bg[1], bg[2]]); + Some(out) +} + /// A line of text the host draws with its own font. #[derive(Clone, Debug, PartialEq)] pub struct Text { diff --git a/src/scene/model/underlay_vector/wires.rs b/src/scene/model/underlay_vector/wires.rs new file mode 100644 index 000000000..eade84736 --- /dev/null +++ b/src/scene/model/underlay_vector/wires.rs @@ -0,0 +1,197 @@ +//! A DWF sheet or DGN model drawn as vector wires (lines and flat fills) +//! in world space, so it stays sharp at every zoom as the reference draws +//! it, instead of as a raster rescaled to the view. + +use std::collections::HashMap; + +use codec::entities::{Underlay, UnderlayDefinition, UnderlayDisplayFlags, UnderlayType}; + +use super::{model, sheet_without, Backdrop, Segment, Sheet}; +use crate::scene::model::wire_model::WireModel; + +/// Wires for the underlay's content, one per colour and kind (lines, fills), +/// named after the underlay so they draw in its place. They are drawn and +/// plotted but never picked or snapped (the frame selects the underlay; the +/// underlay's own snap geometry serves object snaps). +pub(crate) fn display_wires( + u: &Underlay, + def: &UnderlayDefinition, + name: &str, + background: [f32; 4], +) -> Vec { + if def.underlay_type == UnderlayType::Pdf || def.unloaded || !u.flags.contains(UnderlayDisplayFlags::ON) { + return Vec::new(); + } + let page = crate::entities::underlay::page_of(def); + let hidden = crate::scene::model::pdf_layers::hidden_layers(u); + let Some(sheet) = sheet_without(def.underlay_type, &def.file_path, page, &hidden) else { + return Vec::new(); + }; + let adjust = crate::scene::model::image_model::underlay_adjust(u, def, background); + let backdrop = Backdrop::of([background[0], background[1], background[2]]); + // A DGN model's fade is in its colours; a DWF sheet's blends it with + // what is under it. + let alpha = if def.underlay_type == UnderlayType::Dgn { 1.0 } else { 1.0 - u.fade.min(100) as f32 / 100.0 }; + let shown = |c: [u8; 3]| -> [f32; 4] { + let c = if sheet.table_colors { model::dgn_table_color(c, backdrop.max_channel, backdrop.light) } else { c }; + let rgb = crate::scene::model::pdf_raster::adjust_rgb([c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0], &adjust); + [rgb[0], rgb[1], rgb[2], alpha] + }; + let world = |p: [f64; 2]| crate::entities::underlay::local_to_world(u, p); + let tolerance = curve_tolerance(&sheet); + let scale = u.x_scale.abs().max(u.y_scale.abs()); + + // Lines per (colour, width), fills per colour, each in first-drawn order. + let key = |c: [f32; 4]| c.map(f32::to_bits); + let mut lines: Vec<([f32; 4], f64, Vec<[f64; 3]>)> = Vec::new(); + let mut line_at: HashMap<([u32; 4], u64), usize> = HashMap::new(); + let mut fills: Vec<([f32; 4], Vec<[f64; 3]>)> = Vec::new(); + let mut fill_at: HashMap<[u32; 4], usize> = HashMap::new(); + for path in &sheet.paths { + if let Some(c) = path.fill { + let color = shown(c); + let rings: Vec> = path + .subpaths + .iter() + .map(|sp| polyline(&sp.segments, tolerance)) + .filter(|r| r.len() >= 3) + .collect(); + if !rings.is_empty() { + let (points, triangles) = kernel::geom2d::triangulate_rings(&rings); + let at = *fill_at.entry(key(color)).or_insert_with(|| { + fills.push((color, Vec::new())); + fills.len() - 1 + }); + for triangle in triangles { + for index in triangle { + if let Some(&p) = points.get(index) { + fills[at].1.push(world(p)); + } + } + } + } + } + if let Some((c, width)) = path.stroke { + let color = shown(c); + let at = *line_at.entry((key(color), width.to_bits())).or_insert_with(|| { + lines.push((color, width, Vec::new())); + lines.len() - 1 + }); + for sp in &path.subpaths { + let mut pts = polyline(&sp.segments, tolerance); + if sp.closed && pts.len() >= 2 && pts.first() != pts.last() { + pts.push(pts[0]); + } + if pts.len() < 2 { + continue; + } + let run = &mut lines[at].2; + if !run.is_empty() { + run.push([f64::NAN; 3]); + } + run.extend(pts.into_iter().map(world)); + } + } + } + + let mut wires = Vec::new(); + for (color, tris) in fills { + let (fp, fp_low) = crate::scene::convert::tessellate::points_to_ds(tris); + let mut wire = content_wire(name, Vec::new(), color); + wire.fill_tris = fp; + wire.fill_tris_low = fp_low; + wires.push(wire); + } + for (color, width, points) in lines { + let (lp, lp_low) = crate::scene::convert::tessellate::points_to_ds(points); + let mut wire = content_wire(name, lp, color); + wire.points_low = lp_low; + if width < 0.0 { + // A width in pixels (DGN line weights are not shown). + wire.set_fixed_screen_width((-width) as f32); + } else if width > 0.0 { + wire.world_width = (width * scale) as f32; + } else { + wire.set_fixed_screen_width(1.0); + } + wires.push(wire); + } + if crate::entities::underlay::is_clipped(u) { + let clip: Vec<[f64; 2]> = crate::entities::underlay::clip_polygon_local(u) + .into_iter() + .map(|p| { + let w = world(p); + [w[0], w[1]] + }) + .collect(); + let ring = if u.clip_inverted { + let [x0, y0, x1, y1] = sheet.rect; + let corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(|p| { + let w = world(p); + [w[0], w[1]] + }); + let lo = [corners.iter().map(|p| p[0]).fold(f64::MAX, f64::min), corners.iter().map(|p| p[1]).fold(f64::MAX, f64::min)]; + let hi = [corners.iter().map(|p| p[0]).fold(f64::MIN, f64::max), corners.iter().map(|p| p[1]).fold(f64::MIN, f64::max)]; + crate::scene::pick::xclip::inverted_ring(&clip, (lo, hi)) + } else { + clip + }; + crate::scene::pick::xclip::clip_wires(&mut wires, &ring); + } + for wire in &mut wires { + crate::scene::convert::tess::set_wire_aabb(wire, WireModel::UNBOUNDED_AABB); + } + wires +} + +fn content_wire(name: &str, points: Vec<[f32; 3]>, color: [f32; 4]) -> WireModel { + let mut wire = WireModel::solid(name.to_string(), points, color, false); + wire.aci = 0; + wire.bg_adapt = None; + wire.display_visible = true; + wire.plot_visible = true; + // Drawn, but neither picked nor snapped (see `WireModel::snap_only`). + wire.snap_only = true; + wire +} + +/// How far a curve's polyline may stray from it: a small share of the +/// sheet, so curves stay smooth when zoomed far in. +fn curve_tolerance(sheet: &Sheet) -> f64 { + let [x0, y0, x1, y1] = sheet.rect; + ((x1 - x0).hypot(y1 - y0) * 2e-5).max(1e-9) +} + +/// A subpath's points, curves divided within `tolerance`. +fn polyline(segments: &[Segment], tolerance: f64) -> Vec<[f64; 2]> { + let mut out: Vec<[f64; 2]> = Vec::new(); + for seg in segments { + let start = seg.start(); + if out.last() != Some(&start) { + out.push(start); + } + match seg { + Segment::Line(_, b) => out.push(*b), + Segment::Cubic(a, c1, c2, b) => { + // The control polygon's length bounds the curve's; the + // flattening error of n pieces falls as 1/n². + let hull = dist(*a, *c1) + dist(*c1, *c2) + dist(*c2, *b); + let n = ((hull / tolerance).sqrt() * 0.5).ceil().clamp(2.0, 256.0) as usize; + for i in 1..=n { + let t = i as f64 / n as f64; + let u = 1.0 - t; + let (k0, k1, k2, k3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + out.push([ + k0 * a[0] + k1 * c1[0] + k2 * c2[0] + k3 * b[0], + k0 * a[1] + k1 * c1[1] + k2 * c2[1] + k3 * b[1], + ]); + } + } + } + } + out +} + +fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { + (b[0] - a[0]).hypot(b[1] - a[1]) +} diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index dad7fef4d..c872803a6 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -506,8 +506,9 @@ pub struct WireModel { pub depth_override: Option, /// Whether this wire is drawn in the normal viewport pass. pub display_visible: bool, - /// Geometry that only object snaps use (the content of an underlay): - /// never drawn, plotted or picked. + /// Geometry that is never picked: the content of an underlay. Hidden + /// (`display_visible` false) it is what object snaps find; drawn, it is + /// display only and snaps skip it too ([`Self::is_display_only`]). pub snap_only: bool, /// Whether this wire is included in plotted output. pub plot_visible: bool, @@ -531,6 +532,11 @@ impl WireModel { pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0]; /// Rollover (hover) highlight — orange, distinct from the blue selection. pub const HOVER: [f32; 4] = [0.95, 0.55, 0.10, 1.0]; + + /// Drawn underlay content: shown and plotted, never picked or snapped. + pub fn is_display_only(&self) -> bool { + self.snap_only && self.display_visible + } /// Sentinel AABB that never rejects any snap query. pub const UNBOUNDED_AABB: [f32; 4] = [ f32::NEG_INFINITY, diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 0fb4988ae..efc62a2cd 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -673,6 +673,10 @@ impl WireIndexBatch { } fn append_wire_index_entries(wire_idx: u32, wire: &WireModel, batch: &mut WireIndexBatch) { + // Drawn underlay content: neither picked nor snapped. + if wire.is_display_only() { + return; + } if let Some(aabb) = finite_wire_aabb3(wire) { batch.wire_entries.push(Entry3 { aabb, diff --git a/src/scene/view/transform.rs b/src/scene/view/transform.rs index d9ed5fc90..52cc26321 100644 --- a/src/scene/view/transform.rs +++ b/src/scene/view/transform.rs @@ -120,6 +120,50 @@ pub fn reflect_xy_point(x: &mut f64, y: &mut f64, p1: DVec3, p2: DVec3) { *y = p1.y as f64 + my; } +/// An ellipse / elliptical arc's image under any affine transform, through the +/// codec's `transform_ellipse` so the block-cache fast path and the entity +/// agree with every other path (explode, insert) by construction. +/// +/// Returns `(center, major_axis, normal, minor_axis_ratio, start_param, +/// end_param)`; the major axis is the longer one and a reflection flips the +/// normal. +pub fn transform_ellipse_geometry( + center: Vector3, + major_axis: Vector3, + normal: Vector3, + minor_axis_ratio: f64, + start_param: f64, + end_param: f64, + transform: &Transform, +) -> Option<(Vector3, Vector3, Vector3, f64, f64, f64)> { + use codec::Entity; + if normal.length() <= 1e-12 || major_axis.length() <= 1e-12 { + return None; + } + let mut ellipse = codec::entities::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + start_parameter: start_param, + end_parameter: end_param, + ..Default::default() + }; + ellipse.apply_transform(transform); + let values = [ellipse.center, ellipse.major_axis, ellipse.normal]; + if values.iter().any(|v| !(v.x.is_finite() && v.y.is_finite() && v.z.is_finite())) { + return None; + } + Some(( + ellipse.center, + ellipse.major_axis, + ellipse.normal, + ellipse.minor_axis_ratio, + ellipse.start_parameter, + ellipse.end_parameter, + )) +} + /// DXF arbitrary-axis algorithm — returns the OCS X and Y basis vectors in WCS /// for a given entity normal vector. /// @@ -271,3 +315,154 @@ mod ocs_axes_142 { assert!(p.0.abs()+p.1.abs()+p.2.abs() > 1.0, "point collapsed: {:?}", p); } } + +#[cfg(test)] +mod ellipse_transform_tests { + use super::*; + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4}; + + fn sample_point(center: Vector3, major: Vector3, normal: Vector3, ratio: f64, t: f64) -> Vector3 { + let u = major; + let v = normal.normalize().cross(&major) * ratio; + center + u * t.cos() + v * t.sin() + } + + #[test] + fn negative_rotation_negative_scale_distant_origin() { + // Original ellipse arc at distant center (7000, 5000, 0) + let center = Vector3::new(7000.0, 5000.0, 0.0); + let major = Vector3::new(10.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.5; + let start_param = FRAC_PI_4; + let end_param = 3.0 * FRAC_PI_4; + + // Transform: non-uniform negative scale (-1.0, -1.0, 1.0), rotation -45 deg, translation (100, 200, 0) + let rot_rad = -FRAC_PI_4; + let transform = Transform::from_scaling(Vector3::new(-1.0, -1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, rot_rad)) + .then(&Transform::from_translation(Vector3::new(100.0, 200.0, 0.0))); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + assert!(r <= 1.0 && r > 0.0, "ratio must be in (0, 1], got {r}"); + assert!(m.length() >= m.length() * r); + + // Verify that start, end, and intermediate points match exactly + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } + + #[test] + fn axis_swapping_under_non_uniform_negative_scale() { + // When scale along Y is much larger, the minor axis becomes the major axis + let center = Vector3::new(0.0, 0.0, 0.0); + let major = Vector3::new(10.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.5; // initial minor length is 5.0 + let start_param = 0.1; + let end_param = 2.0; + + // Scale X by -1, Scale Y by -3: transformed major length is 10, transformed minor length is 15. + // So axes must swap! + let transform = Transform::from_scaling(Vector3::new(-1.0, -3.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -FRAC_PI_2)); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + assert!(r <= 1.0, "ratio must be <= 1.0 after axis swap, got {r}"); + assert!((r - (10.0 / 15.0)).abs() < 1e-9); + + // Verify points match + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } + + #[test] + fn reflection_handedness_flip_arc_preservation() { + // Reflection across Y axis (scale X by -1, Y by 1) + let center = Vector3::new(50.0, 60.0, 0.0); + let major = Vector3::new(8.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.75; + let start_param = 0.2; + let end_param = 1.8; + + let transform = Transform::from_scaling(Vector3::new(-1.0, 1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -0.6)); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + // Normal must be inverted in Z due to reflection + assert!(n.z < 0.0, "normal Z should be flipped, got {}", n.z); + + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } +} diff --git a/src/snap.rs b/src/snap.rs index dad82e13e..d29883ae2 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1349,7 +1349,7 @@ impl Snapper { let unindexed = wires.segments().is_none(); if unindexed { for w in wires.iter() { - if wire_in_range(w) { + if !w.is_display_only() && wire_in_range(w) { in_range_pts += w.points.len(); in_range_wires.push(w); } diff --git a/src/sys.rs b/src/sys.rs index ff3b2e40f..9c0a9c757 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -429,6 +429,69 @@ pub mod crash_log { crate::config::config_dir().map(|path| path.join("crash_logs")) } + /// The report a given process left behind, if it left one. + /// + /// Reports are named for the process that wrote them, so a caller holding + /// a process id from somewhere else — the GPU crash sentinel does — can + /// ask what killed that exact run instead of guessing from the newest + /// file, which on a busy machine may belong to a different one. + /// + /// Process ids are reused, so only a report written at or after + /// `not_before` (Unix seconds — when that run started) counts: an old + /// report from an unrelated run that once had the same id does not. + pub fn report_for_pid(pid: u32, not_before: u64) -> Option { + let directory = directory()?; + let suffix = format!("-{pid}.log"); + let entry = std::fs::read_dir(directory) + .ok()? + .flatten() + .find(|entry| { + let name = entry.file_name().to_string_lossy().into_owned(); + name.strip_prefix("crash-") + .and_then(|rest| rest.strip_suffix(suffix.as_str())) + .and_then(|when| when.parse::().ok()) + .is_some_and(|when| when >= not_before) + })?; + std::fs::read_to_string(entry.path()).ok() + } + + /// Whether a report describes the graphics device giving up. + /// + /// Deliberately narrow. Anything that reaches wgpu — an exhausted device, + /// a lost one, a buffer that was never created — is the backend's + /// business; a panic in the drawing code that happens to run on a frame + /// is not, and must not cost the user a working backend. + pub fn is_device_failure(report: &str) -> bool { + let mut panic_line = ""; + let mut at_line = ""; + for line in report.lines() { + if let Some(rest) = line.strip_prefix("panic: ") { + panic_line = rest; + } else if let Some(rest) = line.strip_prefix("at: ") { + at_line = rest; + } + } + // The message wgpu panics with names the call, and the location names + // wgpu itself; either alone is enough, since a stripped build can + // leave the message terse. Plain words like "buffer" or "surface" + // are not: drawing code and the modelling kernel panic with those + // too, and that is no reason to give up a working backend. + let haystack = format!("{panic_line} {at_line}").to_ascii_lowercase(); + ["wgpu", "out of memory", "device lost", "validation error"] + .iter() + .any(|needle| haystack.contains(needle)) + } + + /// The backend a report says was live, as written by [`report_from`]. + pub fn backend_in_report(report: &str) -> Option { + report + .lines() + .find_map(|line| line.strip_prefix("gpu backend: ")) + .map(str::trim) + .filter(|backend| !backend.is_empty() && *backend != "(default)") + .map(str::to_string) + } + /// Chain a report writer onto the current panic hook. /// /// Call once, early: a panic before this runs is still silent. @@ -562,6 +625,46 @@ pub mod crash_log { ); } + /// Only the device's own failures may cost a backend. A panic in + /// drawing code that merely happened to run on a frame must not. + #[test] + fn a_device_failure_is_told_apart_from_an_ordinary_panic() { + let device = super::report_from( + "Error in Buffer::get_mapped_range: Validation Error", + "wgpu-29.0.4/src/backend/wgpu_core.rs:2253:18", + 1, + ); + assert!(super::is_device_failure(&device), "{device}"); + + let oom = super::report_from("Out of Memory", "src/scene/pipeline/mod.rs:10:1", 1); + assert!(super::is_device_failure(&oom), "{oom}"); + + let ordinary = super::report_from( + "index out of bounds: the len is 3 but the index is 7", + "src/app/commands/draw.rs:120:5", + 1, + ); + assert!( + !super::is_device_failure(&ordinary), + "an indexing bug is not the graphics device's fault: +{ordinary}" + ); + } + + /// The backend line is what the sentinel joins on, so it has to come + /// back out — and "(default)" names nothing to blame. + #[test] + fn the_backend_is_read_back_out_of_a_report() { + // SAFETY: single-threaded test mutating a process-local variable. + unsafe { std::env::set_var("WGPU_BACKEND", "dx12") }; + let named = super::report_from("boom", "x.rs:1:1", 0); + assert_eq!(super::backend_in_report(&named).as_deref(), Some("dx12")); + + unsafe { std::env::remove_var("WGPU_BACKEND") }; + let unnamed = super::report_from("boom", "x.rs:1:1", 0); + assert_eq!(super::backend_in_report(&unnamed), None); + } + /// A panic with no message still has to produce a filed report rather /// than nothing at all. #[test] diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index cee3e71f4..8946be613 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -146,7 +146,8 @@ pub fn item_thumbs(kind: codec::entities::UnderlayType, path: &str) -> Vec