forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/pointcloud-attach
# Conflicts: # Cargo.lock
This commit is contained in:
commit
3afd547e28
37 files changed
+4603
-302
No files matched your search
Generated
+4
-4
@@ -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",
|
||||
]
|
||||
|
||||
+5
-5
@@ -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" }
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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`
|
||||
|
||||
Generated
+1
-1
@@ -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",
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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::<Vec<_>>()})
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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<u16> {
|
||||
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<Self> {
|
||||
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<String>) {
|
||||
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<Self> {
|
||||
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<String>) {
|
||||
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::<AnyObject>(),
|
||||
wrap: false,
|
||||
inSpellDocumentWithTag: 0isize,
|
||||
wordCount: std::ptr::null_mut::<isize>()
|
||||
];
|
||||
|
||||
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::<AnyObject>(),
|
||||
wrap: false,
|
||||
inSpellDocumentWithTag: 0isize,
|
||||
wordCount: std::ptr::null_mut::<isize>()
|
||||
];
|
||||
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::<AnyObject>(),
|
||||
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<String>,
|
||||
language: String,
|
||||
has_cli_tool: bool,
|
||||
}
|
||||
|
||||
impl LinuxSpeller {
|
||||
pub fn new(lang_tag: &str) -> Option<Self> {
|
||||
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<String>) {
|
||||
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<String>) -> 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<String>)> {
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+71
-13
@@ -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}");
|
||||
}
|
||||
}
|
||||
+121
-9
@@ -319,17 +319,51 @@ pub fn crashed_backends() -> Vec<String> {
|
||||
return Vec::new();
|
||||
};
|
||||
let text = std::fs::read_to_string(p).unwrap_or_default();
|
||||
let named = || -> Vec<String> {
|
||||
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::<u32>().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let armed = sentinel
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("t="))
|
||||
.and_then(|value| value.trim().parse::<u64>().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.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
#![allow(non_snake_case)]
|
||||
#![recursion_limit = "256"]
|
||||
#![recursion_limit = "512"]
|
||||
|
||||
pub mod app;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
+31
-1
@@ -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],
|
||||
|
||||
@@ -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<DVec3>,
|
||||
}
|
||||
|
||||
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<TangentObject>,
|
||||
) -> Option<CmdResult> {
|
||||
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<WireModel> {
|
||||
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<TangentObject>,
|
||||
) -> Vec<WireModel> {
|
||||
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<DVec3> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+73
-13
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
+42
-26
@@ -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]) {
|
||||
|
||||
@@ -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<f64>,
|
||||
) -> Option<Self> {
|
||||
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
|
||||
|
||||
@@ -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<HashMap<AdjustKey, Arc<Vec<u8>>>> {
|
||||
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<Vec<u8>> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<i32> {
|
||||
@@ -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<Vec<[u8; 3]>> {
|
||||
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<Sheet> {
|
||||
}
|
||||
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<Sheet> {
|
||||
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()?;
|
||||
|
||||
@@ -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<Path>,
|
||||
@@ -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::<f64>() + 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<Segment> {
|
||||
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<Vec<f64>> = (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<String, Raw>, 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<String, Vec<&[u8]>>, 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<String, Raw>, 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<Sheet> {
|
||||
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<Sheet> {
|
||||
.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<String, Vec<&[u8]>> = 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<Sheet> {
|
||||
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)
|
||||
}
|
||||
@@ -180,7 +180,7 @@ pub fn sheet(bytes: &[u8], name: &str) -> Option<Sheet> {
|
||||
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
|
||||
|
||||
@@ -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<u16> {
|
||||
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::<f64>() / 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<Arc<PdfPage>> {
|
||||
/// top, memoised per size and backdrop.
|
||||
pub fn display_raster(
|
||||
kind: UnderlayType,
|
||||
path: &str,
|
||||
item: &str,
|
||||
hidden: &[String],
|
||||
side: f64,
|
||||
backdrop: Backdrop,
|
||||
) -> Option<Arc<PdfPage>> {
|
||||
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<PdfPage> {
|
||||
fn rasterize(sheet: &Sheet, side: f64, backdrop: Backdrop) -> Option<PdfPage> {
|
||||
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<PdfPage> {
|
||||
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<PdfPage> {
|
||||
}
|
||||
}
|
||||
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<PdfPage> {
|
||||
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<tiny_skia::Path>, Option<tiny_skia::Path>) {
|
||||
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<HashMap<Key, Option<Arc<super::pdf_vector::PageVectors>>>> {
|
||||
static CACHE: OnceLock<Mutex<HashMap<Key, Option<Arc<super::pdf_vector::PageVectors>>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(Default::default)
|
||||
|
||||
@@ -39,6 +39,47 @@ pub struct Sheet {
|
||||
pub texts: Vec<Text>,
|
||||
/// 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::<u32>() <= 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<f64> {
|
||||
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<Vec<[u8; 3]>> {
|
||||
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 {
|
||||
|
||||
@@ -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<WireModel> {
|
||||
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<Vec<[f64; 2]>> = 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])
|
||||
}
|
||||
@@ -506,8 +506,9 @@ pub struct WireModel {
|
||||
pub depth_override: Option<f32>,
|
||||
/// 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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+103
@@ -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<String> {
|
||||
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::<u64>().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<String> {
|
||||
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]
|
||||
|
||||
@@ -146,7 +146,8 @@ pub fn item_thumbs(kind: codec::entities::UnderlayType, path: &str) -> Vec<PageT
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|label| {
|
||||
let image = underlay_vector::display_raster(kind, path, &label, &[], 220.0)
|
||||
let backdrop = underlay_vector::Backdrop { max_channel: 0, light: false };
|
||||
let image = underlay_vector::display_raster(kind, path, &label, &[], 220.0, backdrop)
|
||||
.map(|page| image::Handle::from_rgba(page.width, page.height, page.pixels.to_vec()));
|
||||
PageThumb { label, image }
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user