forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/pdf-attach-parity
# Conflicts: # locales/ar-SA/opencadstudio.ftl # locales/bg-BG/opencadstudio.ftl # locales/cs-CZ/opencadstudio.ftl # locales/de-DE/opencadstudio.ftl # locales/el-GR/opencadstudio.ftl # locales/en-US/opencadstudio.ftl # locales/es-ES/opencadstudio.ftl # locales/fi-FI/opencadstudio.ftl # locales/fr-FR/opencadstudio.ftl # locales/hi-IN/opencadstudio.ftl # locales/hu-HU/opencadstudio.ftl # locales/it-IT/opencadstudio.ftl # locales/ja-JP/opencadstudio.ftl # locales/ko-KR/opencadstudio.ftl # locales/nl-NL/opencadstudio.ftl # locales/pl-PL/opencadstudio.ftl # locales/pt-BR/opencadstudio.ftl # locales/ru-RU/opencadstudio.ftl # locales/tr-TR/opencadstudio.ftl # locales/zh-CN/opencadstudio.ftl # locales/zh-TW/opencadstudio.ftl # src/app/control/actions.rs # src/app/mod.rs # src/app/update/mod.rs # src/app/view/modal.rs # src/io/xref.rs # src/ui/window/mod.rs
This commit is contained in:
commit
c1ecb0edaf
77 files changed
+5695
-606
No files matched your search
+8
-2
@@ -35,6 +35,11 @@ const DEFAULT_ALIASES_PGP: &str = include_str!("../../assets/ocad.pgp");
|
||||
/// config base can't be resolved (headless, no `HOME`).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn alias_file_path() -> Option<PathBuf> {
|
||||
// Tests read the shipped defaults: parallel tests seeding and migrating one
|
||||
// shared file race, and a half-written table fails unrelated alias tests.
|
||||
if cfg!(test) {
|
||||
return None;
|
||||
}
|
||||
Some(crate::config::config_dir()?.join("ocad.pgp"))
|
||||
}
|
||||
|
||||
@@ -74,8 +79,8 @@ const WEB_ALIAS_KEY: &str = "opencadstudio.aliases";
|
||||
/// defaults add aliases or change a default target, so existing profiles can be
|
||||
/// migrated forward (see `introduced_at` / `migrate_aliases`). Version 1 shipped
|
||||
/// the pre-REDRAW table; version 2 introduced the REDRAW-family aliases; version 3
|
||||
/// introduced HB (HATCHTOBACK).
|
||||
const DEFAULT_ALIASES_VERSION: u32 = 3;
|
||||
/// introduced HB (HATCHTOBACK); version 4 introduced ER (EXTERNALREFERENCES).
|
||||
const DEFAULT_ALIASES_VERSION: u32 = 4;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
const WEB_ALIAS_VERSION_KEY: &str = "opencadstudio.aliases.version";
|
||||
@@ -93,6 +98,7 @@ fn introduced_at(version: u32) -> &'static [(&'static str, &'static str)] {
|
||||
("REA", "REGENALL"),
|
||||
],
|
||||
3 => &[("HB", "HATCHTOBACK")],
|
||||
4 => &[("ER", "EXTERNALREFERENCES")],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
+662
-5
@@ -16,9 +16,12 @@
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use std::io::{BufRead, Write};
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::OpenCADStudio;
|
||||
|
||||
@@ -38,7 +41,11 @@ pub fn serve() {
|
||||
/// writes `output` (format chosen from `output`'s extension), and returns a
|
||||
/// process exit code (0 on success). No window is created.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32 {
|
||||
pub fn export_headless(
|
||||
input: &std::path::Path,
|
||||
output: &std::path::Path,
|
||||
target_version: Option<&str>,
|
||||
) -> i32 {
|
||||
let doc = match crate::io::load_file(input) {
|
||||
Ok(doc) => doc,
|
||||
Err(e) => {
|
||||
@@ -46,7 +53,17 @@ pub fn export_headless(input: &std::path::Path, output: &std::path::Path) -> i32
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
match crate::io::save(&doc, output) {
|
||||
let version = match target_version {
|
||||
Some(value) => match crate::io::parse_target_version(value) {
|
||||
Ok(version) => version,
|
||||
Err(e) => {
|
||||
eprintln!("export: {e}");
|
||||
return 2;
|
||||
}
|
||||
},
|
||||
None => doc.version,
|
||||
};
|
||||
match crate::io::save_as_version(&doc, output, version) {
|
||||
Ok(()) => {
|
||||
println!("Exported {} → {}", input.display(), output.display());
|
||||
0
|
||||
@@ -311,6 +328,202 @@ fn projected_fields(mut entity: Value, fields: Option<&Vec<Value>>) -> Value {
|
||||
entity
|
||||
}
|
||||
|
||||
pub(super) fn requested_save_target(
|
||||
req: &Value,
|
||||
default_version: acadrust::DxfVersion,
|
||||
default_is_dxf: bool,
|
||||
path: Option<&std::path::Path>,
|
||||
) -> Result<(acadrust::DxfVersion, bool), String> {
|
||||
let version = match req["target_version"].as_str() {
|
||||
Some(value) => crate::io::parse_target_version(value)?,
|
||||
None => default_version,
|
||||
};
|
||||
let path_format = path
|
||||
.and_then(std::path::Path::extension)
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.filter(|value| value == "dxf" || value == "dwg");
|
||||
let requested_format = match req["target_format"].as_str() {
|
||||
Some(value) if value.eq_ignore_ascii_case("dxf") => Some("dxf".to_string()),
|
||||
Some(value) if value.eq_ignore_ascii_case("dwg") => Some("dwg".to_string()),
|
||||
Some(value) => return Err(format!("unsupported target format {value:?}; use dwg or dxf")),
|
||||
None => None,
|
||||
};
|
||||
if let (Some(requested), Some(extension)) = (&requested_format, &path_format) {
|
||||
if requested != extension {
|
||||
return Err(format!(
|
||||
"target_format {requested:?} conflicts with output extension .{extension}"
|
||||
));
|
||||
}
|
||||
}
|
||||
let is_dxf = requested_format
|
||||
.or(path_format)
|
||||
.map_or(default_is_dxf, |value| value == "dxf");
|
||||
Ok((version, is_dxf))
|
||||
}
|
||||
|
||||
fn document_manifest(document: &acadrust::CadDocument) -> Value {
|
||||
let mut by_type: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut by_layer: BTreeMap<String, u64> = BTreeMap::new();
|
||||
let mut total = 0u64;
|
||||
for entity in document.entities() {
|
||||
*by_type
|
||||
.entry(crate::entities::names::ui_name(entity).to_string())
|
||||
.or_default() += 1;
|
||||
*by_layer.entry(entity.common().layer.clone()).or_default() += 1;
|
||||
total += 1;
|
||||
}
|
||||
json!({"total":total,"by_type":by_type,"by_layer":by_layer})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn sha256_file(path: &std::path::Path) -> Result<String, String> {
|
||||
let bytes = std::fs::read(path).map_err(|error| error.to_string())?;
|
||||
let digest = Sha256::digest(bytes);
|
||||
Ok(format!("{digest:x}"))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn audit_ascii_dxf_references(path: &std::path::Path) -> Result<Value, String> {
|
||||
let bytes = std::fs::read(path).map_err(|error| error.to_string())?;
|
||||
if bytes.starts_with(b"AutoCAD Binary DXF") {
|
||||
return Ok(json!({
|
||||
"ok":true,
|
||||
"skipped":"binary_dxf",
|
||||
"reason":"raw group-code handle audit applies only to ASCII DXF"
|
||||
}));
|
||||
}
|
||||
let text = String::from_utf8_lossy(&bytes).replace("\r\n", "\n");
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
if lines.len() % 2 != 0 {
|
||||
return Ok(json!({"ok":false,"malformed_pairs":true,"duplicate_handles":[],"dangling_handles":[]}));
|
||||
}
|
||||
let mut declared = HashSet::from(["0".to_string()]);
|
||||
let mut duplicate = std::collections::BTreeSet::new();
|
||||
let mut references = Vec::new();
|
||||
let mut pairs = Vec::new();
|
||||
let mut version = None;
|
||||
let mut variable = None;
|
||||
for pair in lines.chunks_exact(2) {
|
||||
let Ok(code) = pair[0].trim().parse::<i32>() else {
|
||||
return Ok(json!({"ok":false,"malformed_pairs":true,"duplicate_handles":[],"dangling_handles":[]}));
|
||||
};
|
||||
let value = pair[1].trim().to_ascii_uppercase();
|
||||
pairs.push((code, value.clone()));
|
||||
if code == 9 {
|
||||
variable = Some(value.clone());
|
||||
} else if code == 1 && variable.as_deref() == Some("$ACADVER") {
|
||||
version = Some(value.clone());
|
||||
variable = None;
|
||||
}
|
||||
if code == 5 || code == 105 {
|
||||
if !declared.insert(value.clone()) {
|
||||
duplicate.insert(value.clone());
|
||||
}
|
||||
} else if [330, 340, 350, 360, 390].contains(&code) {
|
||||
references.push(value);
|
||||
}
|
||||
}
|
||||
let dangling: std::collections::BTreeSet<String> = references
|
||||
.into_iter()
|
||||
.filter(|handle| !declared.contains(handle))
|
||||
.collect();
|
||||
let mut in_objects = false;
|
||||
let mut pending_section = false;
|
||||
let mut records: Vec<(String, Vec<(i32, String)>)> = Vec::new();
|
||||
let mut current: Option<(String, Vec<(i32, String)>)> = None;
|
||||
for (code, value) in &pairs {
|
||||
if *code == 0 && value == "SECTION" {
|
||||
pending_section = true;
|
||||
continue;
|
||||
}
|
||||
if pending_section && *code == 2 {
|
||||
in_objects = value == "OBJECTS";
|
||||
pending_section = false;
|
||||
continue;
|
||||
}
|
||||
if *code == 0 && value == "ENDSEC" {
|
||||
if let Some(record) = current.take() {
|
||||
records.push(record);
|
||||
}
|
||||
in_objects = false;
|
||||
continue;
|
||||
}
|
||||
if !in_objects {
|
||||
continue;
|
||||
}
|
||||
if *code == 0 {
|
||||
if let Some(record) = current.take() {
|
||||
records.push(record);
|
||||
}
|
||||
current = Some((value.clone(), Vec::new()));
|
||||
} else if let Some((_, fields)) = &mut current {
|
||||
fields.push((*code, value.clone()));
|
||||
}
|
||||
}
|
||||
let root = records.iter().find_map(|(kind, fields)| {
|
||||
let handle = fields
|
||||
.iter()
|
||||
.find(|(code, _)| *code == 5 || *code == 105)?
|
||||
.1
|
||||
.clone();
|
||||
let owner = fields
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(code, _)| *code == 330)?
|
||||
.1
|
||||
.as_str();
|
||||
(kind == "DICTIONARY" && owner == "0").then_some(handle)
|
||||
});
|
||||
let mut orphaned_objects = std::collections::BTreeSet::new();
|
||||
if let Some(root_handle) = root {
|
||||
let root_targets: HashSet<String> = records
|
||||
.iter()
|
||||
.find(|(_, fields)| {
|
||||
fields
|
||||
.iter()
|
||||
.any(|(code, value)| (*code == 5 || *code == 105) && value == &root_handle)
|
||||
})
|
||||
.map(|(_, fields)| {
|
||||
fields
|
||||
.iter()
|
||||
.filter(|(code, _)| *code == 350 || *code == 360)
|
||||
.map(|(_, value)| value.clone())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for (_, fields) in &records {
|
||||
let Some(handle) = fields
|
||||
.iter()
|
||||
.find(|(code, _)| *code == 5 || *code == 105)
|
||||
.map(|(_, value)| value)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let owner = fields
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|(code, _)| *code == 330)
|
||||
.map(|(_, value)| value.as_str());
|
||||
if owner == Some(root_handle.as_str())
|
||||
&& handle != &root_handle
|
||||
&& !root_targets.contains(handle)
|
||||
{
|
||||
orphaned_objects.insert(handle.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(json!({
|
||||
"ok":duplicate.is_empty() && dangling.is_empty() && orphaned_objects.is_empty(),
|
||||
"malformed_pairs":false,
|
||||
"declared_handles":declared.len() - 1,
|
||||
"duplicate_handles":duplicate,
|
||||
"dangling_handles":dangling,
|
||||
"orphaned_objects":orphaned_objects,
|
||||
"declared_version":version,
|
||||
}))
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Handle one JSON request line and return the JSON response.
|
||||
#[cfg(any(test, not(target_arch = "wasm32")))]
|
||||
@@ -464,6 +677,7 @@ impl OpenCADStudio {
|
||||
})
|
||||
}
|
||||
"entities" => self.entity_summary(),
|
||||
"audit" => self.document_audit(&req),
|
||||
"query" => self.entity_query(&req),
|
||||
"records" => self.record_query(&req),
|
||||
"record_schema" => self.record_schema(&req),
|
||||
@@ -583,7 +797,7 @@ impl OpenCADStudio {
|
||||
".{}.tmp.dwg",
|
||||
path.file_name().unwrap().to_string_lossy()
|
||||
));
|
||||
let document = self.tabs[i].scene.document.clone();
|
||||
let document = self.tabs[i].scene.document_for_save();
|
||||
if let Err(e) = crate::io::save(&document, &scratch) {
|
||||
return err(format!("save: {e}"));
|
||||
}
|
||||
@@ -592,14 +806,48 @@ impl OpenCADStudio {
|
||||
}
|
||||
return json!({ "ok": true, "saved": path.to_string_lossy() });
|
||||
}
|
||||
let default_is_dxf = crate::io::source_is_dxf(
|
||||
self.tabs[i].current_path.as_deref(),
|
||||
&self.tabs[i].scene.document,
|
||||
);
|
||||
let (version, is_dxf) = match requested_save_target(
|
||||
&req,
|
||||
self.tabs[i].scene.document.version,
|
||||
default_is_dxf,
|
||||
Some(&path),
|
||||
) {
|
||||
Ok(target) => target,
|
||||
Err(error) => return err(format!("save: {error}")),
|
||||
};
|
||||
let dropped = crate::io::dropped_on_save_count(
|
||||
&self.tabs[i].scene.document,
|
||||
version,
|
||||
is_dxf,
|
||||
);
|
||||
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
|
||||
return err(format!(
|
||||
"save: conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"
|
||||
));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let result = self.save_tab_synchronously_protected(i, path.clone(), true);
|
||||
let result = self.save_tab_synchronously_protected_as(
|
||||
i,
|
||||
path.clone(),
|
||||
version,
|
||||
true,
|
||||
);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let result = crate::io::save(&self.tabs[i].scene.document, &path)
|
||||
.map_err(crate::io::SaveFailure::other);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
json!({ "ok": true, "saved": path.to_string_lossy() })
|
||||
json!({
|
||||
"ok": true,
|
||||
"saved": path.to_string_lossy(),
|
||||
"target_format": if is_dxf { "dxf" } else { "dwg" },
|
||||
"target_version": format!("{version:?}"),
|
||||
"dropped_on_save": dropped,
|
||||
})
|
||||
}
|
||||
Err(e) => err(format!("save: {e}")),
|
||||
}
|
||||
@@ -888,6 +1136,276 @@ impl OpenCADStudio {
|
||||
})
|
||||
}
|
||||
|
||||
/// Pre-delivery check of the active document against an intended output
|
||||
/// format and version: dangling layers/blocks, duplicate handles,
|
||||
/// non-finite bounds, lossy records and the source DXF handle graph.
|
||||
fn document_audit(&self, req: &Value) -> Value {
|
||||
let i = self.active_tab;
|
||||
let tab = &self.tabs[i];
|
||||
let document = &tab.scene.document;
|
||||
let source_is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), document);
|
||||
let path = req["path"]
|
||||
.as_str()
|
||||
.map(std::path::Path::new)
|
||||
.or(tab.current_path.as_deref());
|
||||
let (target_version, target_is_dxf) = match requested_save_target(
|
||||
req,
|
||||
document.version,
|
||||
source_is_dxf,
|
||||
path,
|
||||
) {
|
||||
Ok(target) => target,
|
||||
Err(error) => return err(format!("audit: {error}")),
|
||||
};
|
||||
|
||||
let declared_layers: HashSet<String> = document
|
||||
.layers
|
||||
.iter()
|
||||
.map(|layer| layer.name.to_ascii_uppercase())
|
||||
.collect();
|
||||
let block_names: HashSet<String> = document
|
||||
.block_records
|
||||
.iter()
|
||||
.map(|block| block.name.to_ascii_uppercase())
|
||||
.collect();
|
||||
let mut missing_layers = std::collections::BTreeSet::new();
|
||||
let mut missing_blocks = std::collections::BTreeSet::new();
|
||||
let mut duplicate_handles = std::collections::BTreeSet::new();
|
||||
let mut seen_handles = HashSet::new();
|
||||
let mut serialization_errors = Vec::new();
|
||||
let mut non_finite_bounds = Vec::new();
|
||||
let mut unknown_entities = 0usize;
|
||||
let mut bounds: Option<([f64; 3], [f64; 3])> = None;
|
||||
|
||||
for entity in document.entities() {
|
||||
let common = entity.common();
|
||||
if !declared_layers.contains(&common.layer.to_ascii_uppercase()) {
|
||||
missing_layers.insert(common.layer.clone());
|
||||
}
|
||||
if !seen_handles.insert(common.handle.value()) {
|
||||
duplicate_handles.insert(format!("{:X}", common.handle.value()));
|
||||
}
|
||||
if let acadrust::EntityType::Insert(insert) = entity {
|
||||
if !block_names.contains(&insert.block_name.to_ascii_uppercase()) {
|
||||
missing_blocks.insert(insert.block_name.clone());
|
||||
}
|
||||
}
|
||||
if matches!(entity, acadrust::EntityType::Unknown(_)) {
|
||||
unknown_entities += 1;
|
||||
}
|
||||
if let Err(error) = serde_json::to_value(entity) {
|
||||
serialization_errors.push(format!("{:X}: {error}", common.handle.value()));
|
||||
}
|
||||
let (min, max) = crate::scene::convert::tess::entity_bounds(entity);
|
||||
if min.into_iter().chain(max).any(|value| !value.is_finite()) {
|
||||
non_finite_bounds.push(format!("{:X}", common.handle.value()));
|
||||
} else {
|
||||
bounds = Some(match bounds {
|
||||
None => (min, max),
|
||||
Some((mut all_min, mut all_max)) => {
|
||||
for axis in 0..3 {
|
||||
all_min[axis] = all_min[axis].min(min[axis]);
|
||||
all_max[axis] = all_max[axis].max(max[axis]);
|
||||
}
|
||||
(all_min, all_max)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
for (handle, object) in &document.objects {
|
||||
if let Err(error) = serde_json::to_value(object) {
|
||||
serialization_errors.push(format!("object {:X}: {error}", handle.value()));
|
||||
}
|
||||
}
|
||||
|
||||
let dropped = crate::io::dropped_on_save_count(
|
||||
document,
|
||||
target_version,
|
||||
target_is_dxf,
|
||||
);
|
||||
let mut issues = Vec::new();
|
||||
if !missing_layers.is_empty() {
|
||||
issues.push(json!({"severity":"error","code":"undeclared_layer","values":missing_layers}));
|
||||
}
|
||||
if !missing_blocks.is_empty() {
|
||||
issues.push(json!({"severity":"error","code":"missing_block","values":missing_blocks}));
|
||||
}
|
||||
if !duplicate_handles.is_empty() {
|
||||
issues.push(json!({"severity":"error","code":"duplicate_entity_handle","values":duplicate_handles}));
|
||||
}
|
||||
if !serialization_errors.is_empty() {
|
||||
issues.push(json!({"severity":"error","code":"non_serializable_record","values":serialization_errors}));
|
||||
}
|
||||
if !non_finite_bounds.is_empty() {
|
||||
issues.push(json!({"severity":"error","code":"non_finite_bounds","handles":non_finite_bounds}));
|
||||
}
|
||||
if dropped > 0 {
|
||||
issues.push(json!({
|
||||
"severity":"warning",
|
||||
"code":"lossy_conversion",
|
||||
"message":format!("{dropped} unsupported record(s) would be dropped"),
|
||||
}));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let source_dxf_structure = if source_is_dxf {
|
||||
tab.current_path
|
||||
.as_deref()
|
||||
.filter(|path| path.is_file())
|
||||
.and_then(|path| audit_ascii_dxf_references(path).ok())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let source_dxf_structure: Option<Value> = None;
|
||||
if source_dxf_structure
|
||||
.as_ref()
|
||||
.is_some_and(|audit| audit["ok"] != true)
|
||||
{
|
||||
issues.push(json!({
|
||||
"severity":"error","code":"invalid_dxf_handle_graph",
|
||||
"details":source_dxf_structure.clone(),
|
||||
}));
|
||||
}
|
||||
let errors = issues.iter().filter(|issue| issue["severity"] == "error").count();
|
||||
let warnings = issues.iter().filter(|issue| issue["severity"] == "warning").count();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let source_sha256 = tab.current_path.as_deref().filter(|path| path.is_file())
|
||||
.and_then(|path| sha256_file(path).ok());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let source_sha256: Option<String> = None;
|
||||
json!({
|
||||
"ok": errors == 0,
|
||||
"status": if errors > 0 { "failed" } else if warnings > 0 { "warning" } else { "passed" },
|
||||
"document_id": tab.id,
|
||||
"source": {
|
||||
"path": tab.current_path,
|
||||
"format": if source_is_dxf { "dxf" } else { "dwg" },
|
||||
"version": format!("{:?}", document.version),
|
||||
"sha256": source_sha256,
|
||||
"dxf_structure": source_dxf_structure,
|
||||
},
|
||||
"target": {
|
||||
"format": if target_is_dxf { "dxf" } else { "dwg" },
|
||||
"version": format!("{target_version:?}"),
|
||||
"dropped_on_save": dropped,
|
||||
"lossless": dropped == 0,
|
||||
},
|
||||
"manifest": document_manifest(document),
|
||||
"bounds": bounds.map(|(min,max)| json!({"min":min,"max":max})),
|
||||
"unknown_entities": unknown_entities,
|
||||
"issues": issues,
|
||||
"summary": {"errors":errors,"warnings":warnings},
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn save_verified_request(&mut self, req: &Value) -> Result<Value, Value> {
|
||||
let i = self.active_tab;
|
||||
let Some(raw_path) = req["path"].as_str() else {
|
||||
return Err(json!({"ok":false,"status":"failed","code":"path_required","error":"save_verified requires an explicit absolute path"}));
|
||||
};
|
||||
let path = std::path::PathBuf::from(raw_path);
|
||||
if !path.is_absolute() {
|
||||
return Err(json!({"ok":false,"status":"failed","code":"absolute_path_required","error":"save_verified path must be absolute"}));
|
||||
}
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.map(str::to_ascii_lowercase);
|
||||
if !matches!(extension.as_deref(), Some("dwg" | "dxf")) {
|
||||
return Err(json!({"ok":false,"status":"failed","code":"unsupported_format","error":"save_verified path must end in .dwg or .dxf"}));
|
||||
}
|
||||
if path.exists() && req["overwrite"].as_bool() != Some(true) {
|
||||
return Err(json!({"ok":false,"status":"failed","code":"destination_exists","error":"destination exists; set overwrite=true to replace it"}));
|
||||
}
|
||||
let document = &self.tabs[i].scene.document;
|
||||
let source_is_dxf = crate::io::source_is_dxf(self.tabs[i].current_path.as_deref(), document);
|
||||
let (version, is_dxf) = requested_save_target(
|
||||
req,
|
||||
document.version,
|
||||
source_is_dxf,
|
||||
Some(&path),
|
||||
)
|
||||
.map_err(|error| json!({"ok":false,"status":"failed","code":"invalid_target","error":error}))?;
|
||||
let audit = self.document_audit(req);
|
||||
if audit["summary"]["errors"].as_u64().unwrap_or(0) > 0 {
|
||||
return Err(json!({
|
||||
"ok":false,"status":"failed","code":"audit_failed",
|
||||
"error":"pre-save structural audit failed","audit":audit,
|
||||
}));
|
||||
}
|
||||
let dropped = crate::io::dropped_on_save_count(document, version, is_dxf);
|
||||
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
|
||||
return Err(json!({
|
||||
"ok":false,"status":"failed","code":"lossy_conversion_not_acknowledged",
|
||||
"error":format!("conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"),
|
||||
"dropped_on_save":dropped,
|
||||
}));
|
||||
}
|
||||
|
||||
self.prepare_native_save(i);
|
||||
// The same snapshot a normal save writes: display-only overrides and
|
||||
// resolved xref content stay out of the file.
|
||||
let snapshot = self.tabs[i].scene.document_for_save();
|
||||
let before = document_manifest(&snapshot);
|
||||
crate::io::save_as_version(&snapshot, &path, version)
|
||||
.map_err(|error| json!({
|
||||
"ok":false,"status":"failed","code":"save_failed","error":error,
|
||||
}))?;
|
||||
let sha256 = sha256_file(&path).map_err(|error| json!({
|
||||
"ok":false,"status":"failed","code":"hash_failed","error":error,
|
||||
"saved":path,
|
||||
}))?;
|
||||
let dxf_structure = if is_dxf {
|
||||
Some(audit_ascii_dxf_references(&path).map_err(|error| json!({
|
||||
"ok":false,"status":"failed","code":"dxf_audit_failed","error":error,
|
||||
"saved":path,"sha256":sha256,
|
||||
}))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if dxf_structure
|
||||
.as_ref()
|
||||
.is_some_and(|audit| audit["ok"] != true)
|
||||
{
|
||||
return Err(json!({
|
||||
"ok":false,"status":"failed","code":"invalid_dxf_handle_graph",
|
||||
"error":"saved DXF contains duplicate or dangling handle references; file was preserved for diagnosis",
|
||||
"saved":path,"sha256":sha256,"dxf_structure":dxf_structure,
|
||||
"target_version":format!("{version:?}"),
|
||||
}));
|
||||
}
|
||||
let reopened = crate::io::load_file(&path).map_err(|error| json!({
|
||||
"ok":false,"status":"failed","code":"reopen_failed","error":error,
|
||||
"saved":path,"sha256":sha256,
|
||||
}))?;
|
||||
if reopened.version != version {
|
||||
return Err(json!({
|
||||
"ok":false,"status":"failed","code":"version_mismatch",
|
||||
"error":"saved drawing reopened with a different CAD version",
|
||||
"saved":path,"sha256":sha256,"requested":format!("{version:?}"),
|
||||
"actual":format!("{:?}",reopened.version),"dxf_structure":dxf_structure,
|
||||
}));
|
||||
}
|
||||
let after = document_manifest(&reopened);
|
||||
if before != after {
|
||||
return Err(json!({
|
||||
"ok":false,"status":"failed","code":"semantic_mismatch",
|
||||
"error":"saved drawing reopened but its entity manifest changed; file was preserved for diagnosis",
|
||||
"saved":path,"sha256":sha256,"before":before,"after":after,
|
||||
"target_format":if is_dxf { "dxf" } else { "dwg" },
|
||||
"target_version":format!("{version:?}"),"dropped_on_save":dropped,
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"ok":true,"status":"completed","verified":true,
|
||||
"saved":path,"sha256":sha256,"bytes":std::fs::metadata(&path).map(|m|m.len()).unwrap_or(0),
|
||||
"target_format":if is_dxf { "dxf" } else { "dwg" },
|
||||
"target_version":format!("{version:?}"),"dropped_on_save":dropped,
|
||||
"manifest":after,"audit":audit,"dxf_structure":dxf_structure,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Count of entities in the active document, total and by type.
|
||||
fn entity_summary(&self) -> Value {
|
||||
let i = self.active_tab;
|
||||
@@ -948,6 +1466,118 @@ mod tests {
|
||||
use crate::app::OpenCADStudio;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
#[test]
|
||||
fn audit_rejects_an_undeclared_entity_layer() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
assert_eq!(
|
||||
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#)["ok"],
|
||||
true
|
||||
);
|
||||
let i = app.active_tab;
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.entities_mut()
|
||||
.next()
|
||||
.expect("line")
|
||||
.common_mut()
|
||||
.layer = "NOT_DECLARED".into();
|
||||
let audit = app.automation_op(r#"{"op":"audit","target_format":"dwg","target_version":"R14"}"#);
|
||||
assert_eq!(audit["ok"], false, "{audit}");
|
||||
assert_eq!(audit["issues"][0]["code"], "undeclared_layer", "{audit}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_verified_writes_reopens_hashes_and_matches_manifest() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
assert_eq!(
|
||||
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,0"}"#)["ok"],
|
||||
true
|
||||
);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ocs_verified_save_{}_{}.dwg",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let result = app
|
||||
.save_verified_request(&serde_json::json!({
|
||||
"path":path,
|
||||
"target_format":"dwg",
|
||||
"target_version":"R14",
|
||||
"overwrite":true,
|
||||
}))
|
||||
.expect("verified save");
|
||||
assert_eq!(result["verified"], true, "{result}");
|
||||
assert_eq!(result["target_version"], "AC1014", "{result}");
|
||||
assert_eq!(result["manifest"]["total"], 1, "{result}");
|
||||
assert_eq!(result["sha256"].as_str().map(str::len), Some(64));
|
||||
assert!(path.is_file());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_target_version_parser_never_silently_defaults() {
|
||||
assert_eq!(
|
||||
crate::io::parse_target_version("R14").unwrap(),
|
||||
acadrust::DxfVersion::AC1014
|
||||
);
|
||||
assert_eq!(
|
||||
crate::io::parse_target_version("AC1015").unwrap(),
|
||||
acadrust::DxfVersion::AC1015
|
||||
);
|
||||
assert!(crate::io::parse_target_version("R12").is_err());
|
||||
assert!(crate::io::parse_target_version("future").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_dxf_audit_detects_dangling_handle_references() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ocs_dangling_handle_{}.dxf",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, " 0\nLINE\n 5\n1\n330\n2\n").unwrap();
|
||||
let audit = super::audit_ascii_dxf_references(&path).unwrap();
|
||||
assert_eq!(audit["ok"], false, "{audit}");
|
||||
assert_eq!(audit["dangling_handles"], serde_json::json!(["2"]));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_dxf_audit_marks_binary_input_as_skipped() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ocs_binary_dxf_{}.dxf",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(&path, b"AutoCAD Binary DXF\r\n\x1a\0").unwrap();
|
||||
let audit = super::audit_ascii_dxf_references(&path).unwrap();
|
||||
assert_eq!(audit["ok"], true, "{audit}");
|
||||
assert_eq!(audit["skipped"], "binary_dxf", "{audit}");
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_dxf_audit_detects_objects_orphaned_from_the_root_dictionary() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ocs_orphaned_object_{}.dxf",
|
||||
std::process::id()
|
||||
));
|
||||
std::fs::write(
|
||||
&path,
|
||||
"0\nSECTION\n2\nOBJECTS\n0\nDICTIONARY\n5\nC\n330\n0\n0\nDICTIONARY\n5\n23\n330\nC\n0\nENDSEC\n0\nEOF\n",
|
||||
)
|
||||
.unwrap();
|
||||
let audit = super::audit_ascii_dxf_references(&path).unwrap();
|
||||
assert_eq!(audit["ok"], false, "{audit}");
|
||||
assert_eq!(audit["orphaned_objects"], serde_json::json!(["23"]));
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_notice_skips_grid_camera_and_scene_builds() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
@@ -1018,6 +1648,33 @@ mod tests {
|
||||
assert_eq!(assert_one_text(&app), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_extents_includes_sdf_text_outside_other_geometry() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
let _ = app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
|
||||
let line = app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 200,0"}"#);
|
||||
assert_eq!(line["added"], 1, "{line}");
|
||||
let text = app.automation_op(
|
||||
r#"{"op":"run","cmd":"TEXT 100,135 8 0 OPEN CAD MCP ACCEPTANCE"}"#,
|
||||
);
|
||||
assert_eq!(text["added"], 1, "{text}");
|
||||
|
||||
app.tabs[i].scene.selection.borrow_mut().vp_size = (1600.0, 800.0);
|
||||
app.tabs[i].scene.fit_all();
|
||||
let (_, max) = app.tabs[i]
|
||||
.scene
|
||||
.camera
|
||||
.borrow()
|
||||
.fitted_model_bounds()
|
||||
.expect("ZOOM EXTENTS did not fit the drawing");
|
||||
assert!(
|
||||
max.y >= 142.0,
|
||||
"text at y=135..143 was clipped from fitted bounds: {max:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stray_editor_is_not_hijacked_by_the_next_line() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
+16
-55
@@ -711,8 +711,12 @@ impl OpenCADStudio {
|
||||
let toks: Vec<String> = tokens.iter().map(|s| s.to_string()).collect();
|
||||
return self.finish_active_command(&toks, finish);
|
||||
}
|
||||
// Bare XREF opens the External References palette, so probing the
|
||||
// verb alone would open it (and refresh every entry) before the
|
||||
// argument form `XREF Reload A` runs.
|
||||
if tokens[0].eq_ignore_ascii_case("BACKGROUND")
|
||||
|| tokens[0].eq_ignore_ascii_case("COLORSCHEME")
|
||||
|| tokens[0].eq_ignore_ascii_case("XREF")
|
||||
{
|
||||
return self.dispatch_command(cmd);
|
||||
}
|
||||
@@ -3127,60 +3131,17 @@ impl OpenCADStudio {
|
||||
self.sync_dyn_fields();
|
||||
self.refresh_area_preview(i);
|
||||
}
|
||||
CmdResult::CommitAndExit(mut entity) => {
|
||||
// For XATTACH: ensure the xref block definition exists before
|
||||
// committing the INSERT entity that references it.
|
||||
// Extract path early to avoid borrow conflicts.
|
||||
let xattach_path: Option<String> = {
|
||||
let tab = &self.tabs[i];
|
||||
if let Some(cmd) = tab.active_cmd.as_ref() {
|
||||
if cmd.name() == "XATTACH" {
|
||||
cmd.xattach_path()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(path) = xattach_path {
|
||||
// XREF-Task6: the host drawing path lives in the tab
|
||||
// (`current_path`), not in `Scene`, so the self-attach
|
||||
// guard runs here via the pure `is_self_attach` helper.
|
||||
let host_file = self.tabs[i].current_path.clone();
|
||||
let host_base = host_file
|
||||
.as_deref()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf());
|
||||
if let Some(host) = host_file.as_deref() {
|
||||
if crate::modules::insert::xattach::is_self_attach(
|
||||
host,
|
||||
&path,
|
||||
host_base.as_deref(),
|
||||
) {
|
||||
self.command_line.push_error(crate::t!("XATTACH: cannot attach the host drawing into itself.").as_ref());
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.restore_pre_cmd_tangent();
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
let prepared_name = crate::modules::insert::xattach::prepare_xref_block(
|
||||
&mut self.tabs[i].scene,
|
||||
&path,
|
||||
host_base.as_deref(),
|
||||
);
|
||||
// `prepare_xref_block` may suffix a colliding stem. The
|
||||
// command was created before that collision was known, so
|
||||
// retarget its pending INSERT to the actual new block.
|
||||
if let acadrust::EntityType::Insert(insert) = &mut entity {
|
||||
insert.block_name = prepared_name;
|
||||
}
|
||||
// Resolving the xref merged its layer / linetype tables
|
||||
// into the document — mirror them into the Layers panel
|
||||
// and ribbon dropdowns now, not on the next reopen (#407).
|
||||
self.refresh_layer_panel();
|
||||
CmdResult::CommitAndExit(entity) => {
|
||||
// XATTACH: the definition and its INSERT are created together
|
||||
// in one undo step.
|
||||
let xattach_request = self.tabs[i]
|
||||
.active_cmd
|
||||
.as_ref()
|
||||
.filter(|cmd| cmd.name() == "XATTACH")
|
||||
.and_then(|cmd| cmd.xattach_request());
|
||||
if let Some(request) = xattach_request {
|
||||
self.commit_xref_attach(i, request, entity);
|
||||
return Task::none();
|
||||
}
|
||||
let insert_block_name = match &entity {
|
||||
acadrust::EntityType::Insert(ins) => Some(ins.block_name.clone()),
|
||||
@@ -9301,7 +9262,7 @@ impl OpenCADStudio {
|
||||
out
|
||||
}
|
||||
|
||||
fn restore_pre_cmd_tangent(&mut self) {
|
||||
pub(super) fn restore_pre_cmd_tangent(&mut self) {
|
||||
if let Some(was_on) = self.pre_cmd_tangent.take() {
|
||||
if !was_on {
|
||||
self.snapper.enabled.remove(&crate::snap::SnapType::Tangent);
|
||||
|
||||
+106
-13
@@ -460,6 +460,41 @@ impl OpenCADStudio {
|
||||
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
|
||||
}
|
||||
|
||||
cmd if cmd.starts_with("WBLOCK_POINT_PICKED ") => {
|
||||
let parts: Vec<f64> = cmd["WBLOCK_POINT_PICKED ".len()..]
|
||||
.split_whitespace()
|
||||
.filter_map(|s| s.parse::<f64>().ok())
|
||||
.collect();
|
||||
if parts.len() == 3 {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.base_point_x = format!("{:.4}", parts[0]);
|
||||
state.base_point_y = format!("{:.4}", parts[1]);
|
||||
state.base_point_z = format!("{:.4}", parts[2]);
|
||||
}
|
||||
}
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
|
||||
}
|
||||
|
||||
"WBLOCK_POINT_CANCELLED" => {
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
|
||||
}
|
||||
|
||||
"WBLOCK_OBJECTS_GATHERED" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.selected_handles = handles;
|
||||
}
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
|
||||
}
|
||||
|
||||
"INSERT" => {
|
||||
let blocks = self.tabs[i].scene.custom_block_names();
|
||||
if blocks.is_empty() {
|
||||
@@ -718,9 +753,56 @@ impl OpenCADStudio {
|
||||
// Launch the file picker; XAttachPickResult will start the command.
|
||||
return Some(Task::done(Message::XAttachPick));
|
||||
}
|
||||
cmd if cmd == "WBLOCK" || cmd == "WB" || cmd.starts_with("WBLOCK ") => {
|
||||
let arg = cmd.splitn(2, ' ').nth(1).unwrap_or("").trim();
|
||||
if arg.is_empty() {
|
||||
"WBLOCK" | "WB" => {
|
||||
let handles: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
let existing_blocks = self.tabs[i].scene.custom_block_names();
|
||||
let default_unit = self.tabs[i].scene.document.header.insertion_units;
|
||||
let default_folder = self.tabs[i]
|
||||
.current_path
|
||||
.as_ref()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
let default_path = if default_folder.is_empty() {
|
||||
"new_block.dwg".to_string()
|
||||
} else {
|
||||
format!("{}/new_block.dwg", default_folder.replace('\\', "/"))
|
||||
};
|
||||
self.wblock = Some(
|
||||
crate::ui::window::wblock::WblockState::new(
|
||||
existing_blocks,
|
||||
handles,
|
||||
default_unit,
|
||||
default_path,
|
||||
),
|
||||
);
|
||||
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
|
||||
}
|
||||
|
||||
cmd if cmd == "-WBLOCK"
|
||||
|| cmd == "-WB"
|
||||
|| cmd.starts_with("-WBLOCK ")
|
||||
|| cmd.starts_with("-WB ")
|
||||
|| cmd.starts_with("WBLOCK ")
|
||||
|| cmd.starts_with("WB ") =>
|
||||
{
|
||||
let rest = if let Some(r) = cmd.strip_prefix("-WBLOCK") {
|
||||
r.trim()
|
||||
} else if let Some(r) = cmd.strip_prefix("-WB") {
|
||||
r.trim()
|
||||
} else if let Some(r) = cmd.strip_prefix("WBLOCK") {
|
||||
r.trim()
|
||||
} else if let Some(r) = cmd.strip_prefix("WB") {
|
||||
r.trim()
|
||||
} else {
|
||||
""
|
||||
};
|
||||
if rest.is_empty() {
|
||||
// No argument: use selected entities (*) if any, else ask.
|
||||
let sel: Vec<_> = self.tabs[i].scene.selected.iter().copied().collect();
|
||||
if sel.is_empty() {
|
||||
@@ -731,7 +813,7 @@ impl OpenCADStudio {
|
||||
return Some(Task::done(Message::WblockSave("*".to_string())));
|
||||
}
|
||||
} else {
|
||||
return Some(Task::done(Message::WblockSave(arg.to_string())));
|
||||
return Some(Task::done(Message::WblockSave(rest.to_string())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1417,11 +1499,16 @@ impl OpenCADStudio {
|
||||
if after.is_empty() {
|
||||
return Some(Task::done(Message::XAttachPick));
|
||||
}
|
||||
let cmd = crate::modules::insert::xattach::XAttachCommand::with_path(
|
||||
after.to_string(),
|
||||
self.start_xref_attach(
|
||||
i,
|
||||
crate::modules::insert::xattach::XrefAttachRequest {
|
||||
path: after.trim_matches('"').to_string(),
|
||||
overlay: false,
|
||||
path_type: crate::io::xref_model::Pathtype::Full,
|
||||
},
|
||||
crate::modules::insert::xattach::XrefPlacement::on_screen(),
|
||||
"-XREF",
|
||||
);
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
} else {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF: unknown option '{}'. Options: ? Reload Unload Detach Path Pathtype Bind Overlay Attach",
|
||||
@@ -1648,11 +1735,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn xref_dash_alias_lists_like_xref() {
|
||||
// `-XREF` is accepted as an alias for industry muscle memory; bare
|
||||
// `XREF` keeps the legacy list output for compatibility.
|
||||
// `-XREF` asks on the command line: `?` then Enter prints the
|
||||
// reference table; `XREF ?` keeps the legacy list output for scripts.
|
||||
let mut app = fresh_app();
|
||||
let out = run_capture(&mut app, "-XREF ?");
|
||||
assert!(out.contains("No external references") || out.contains("External references"));
|
||||
assert!(out.contains("Total Xref(s): 0"), "got: {out:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2065,9 +2152,15 @@ mod tests {
|
||||
#[test]
|
||||
fn xref_attach_with_file_starts_placement() {
|
||||
// F10: `XREF Attach <file>` threads the file into the XATTACH
|
||||
// placement command (same flow as the picker result).
|
||||
// placement command (same flow as the picker result). A missing file
|
||||
// is rejected up front, so the reference has to exist.
|
||||
let dir = std::env::temp_dir().join(format!("ocs_xref_attach_{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let file = dir.join("plan.dwg");
|
||||
std::fs::write(&file, b"fake").unwrap();
|
||||
let mut app = fresh_app();
|
||||
let out = run_capture(&mut app, "XREF Attach C:/refs/plan.dwg");
|
||||
let out = run_capture(&mut app, &format!("XREF Attach {}", file.to_string_lossy()));
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert!(
|
||||
!out.contains("ships with reference operations"),
|
||||
"dead-end text must be gone, got: {out:?}"
|
||||
|
||||
@@ -5,6 +5,7 @@ use iced::Task;
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod blocks;
|
||||
mod xref_attach;
|
||||
mod dim;
|
||||
pub(crate) mod display;
|
||||
mod draw;
|
||||
@@ -249,6 +250,9 @@ impl OpenCADStudio {
|
||||
if let Some(t) = self.dispatch_layers(cmd, i) {
|
||||
return Some(t);
|
||||
}
|
||||
if let Some(t) = self.dispatch_xref_attach(cmd, i) {
|
||||
return Some(t);
|
||||
}
|
||||
if let Some(t) = self.dispatch_blocks(cmd, i) {
|
||||
return Some(t);
|
||||
}
|
||||
@@ -663,6 +667,7 @@ inventory::submit!(crate::command::CommandRegistration {
|
||||
"3DORBIT",
|
||||
"3O",
|
||||
"ABOUT",
|
||||
"ATTACH",
|
||||
"ATTDISP",
|
||||
"ATTEXT",
|
||||
"BACKGROUND",
|
||||
|
||||
@@ -0,0 +1,479 @@
|
||||
//! Attaching drawing references: ATTACH / XATTACH (file picker, then the
|
||||
//! Attach External Reference dialog), the -XREF command line, the `?` list,
|
||||
//! and the commit that creates the definition and its INSERT as one undo step.
|
||||
|
||||
use super::*;
|
||||
use crate::io::xref_model::Pathtype;
|
||||
use crate::modules::insert::xattach::{
|
||||
existing_reference, path_to_block_name, prepare_xref_definition, stored_path,
|
||||
XAttachCommand, XrefAttachRequest, XrefPlacement,
|
||||
};
|
||||
use crate::modules::insert::xref_cmd::display_path;
|
||||
use crate::ui::window::xref_attach::{XrefAttachMsg, XrefAttachState};
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Commands owned here; `None` defers to the other families.
|
||||
pub(super) fn dispatch_xref_attach(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
|
||||
let upper = cmd.to_ascii_uppercase();
|
||||
let (verb, rest) = match cmd.split_once(char::is_whitespace) {
|
||||
Some((verb, rest)) => (verb.to_ascii_uppercase(), rest.trim()),
|
||||
None => (upper.clone(), ""),
|
||||
};
|
||||
match (verb.as_str(), rest.is_empty()) {
|
||||
("ATTACH", true) => Some(Task::done(Message::AttachPick)),
|
||||
("XATTACH", true) => Some(Task::done(Message::XAttachPick)),
|
||||
// A file named on the command line skips the picker.
|
||||
// Handled at once, so the placement it starts belongs to this
|
||||
// command line (automation included).
|
||||
("ATTACH", false) => Some(self.update(Message::AttachPickResult(Ok(
|
||||
std::path::PathBuf::from(rest.trim_matches('"')),
|
||||
)))),
|
||||
("XATTACH", false) => {
|
||||
self.open_xref_attach_dialog(std::path::PathBuf::from(rest.trim_matches('"')));
|
||||
Some(Task::none())
|
||||
}
|
||||
// XREF opens the External References palette; -XREF asks on the
|
||||
// command line. Both keep their argument forms for scripts.
|
||||
("XREF", true) | ("XREFSHOW", true) => {
|
||||
if !self.show_external_references {
|
||||
return self.dispatch_blocks("EXTERNALREFERENCES", i);
|
||||
}
|
||||
self.refresh_xref_manager();
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
("-XREF", true) => {
|
||||
let saved: Vec<(String, String)> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
.map(|br| (br.name.clone(), br.xref_path.clone()))
|
||||
.collect();
|
||||
let command = crate::modules::insert::xref_cmd::XrefCommand::new(saved);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
Some(Task::none())
|
||||
}
|
||||
("XREFLIST", _) => {
|
||||
self.list_xrefs(i, if rest.is_empty() { "*" } else { rest });
|
||||
Some(self.finish_dispatch(cmd))
|
||||
}
|
||||
("XREFATTACHFILE", false) | ("XREFOVERLAYFILE", false) => {
|
||||
let request = XrefAttachRequest {
|
||||
path: rest.trim_matches('"').to_string(),
|
||||
overlay: verb == "XREFOVERLAYFILE",
|
||||
// The reference default (REFPATHTYPE 1): relative once the
|
||||
// drawing is saved, the full path before that.
|
||||
path_type: Pathtype::Relative,
|
||||
};
|
||||
self.start_xref_attach(i, request, XrefPlacement::on_screen(), "-XREF");
|
||||
Some(Task::none())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// `-XREF ?` — the reference table as the reference application prints it.
|
||||
fn list_xrefs(&mut self, i: usize, pattern: &str) {
|
||||
let rows: Vec<(String, &'static str, String)> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
.filter(|br| {
|
||||
pattern == "*" || crate::io::xref_model::wildcard_match(&br.name, pattern)
|
||||
})
|
||||
.map(|br| {
|
||||
let kind = if br.flags.is_xref_overlay { "Overlay" } else { "Attach" };
|
||||
(br.name.clone(), kind, display_path(&br.xref_path))
|
||||
})
|
||||
.collect();
|
||||
let out = &mut self.command_line;
|
||||
out.push_output("");
|
||||
out.push_output(&format!(
|
||||
"{:<34}{:<13}{}",
|
||||
"Xref name",
|
||||
"Xref Type",
|
||||
"Path"
|
||||
));
|
||||
out.push_output(&format!("{:<34}{:<13}{}", "----------------------", "---------", "----------"));
|
||||
out.push_output("");
|
||||
for (name, kind, path) in &rows {
|
||||
out.push_output(&format!("{:<34}{:<13}{}", format!("\"{name}\""), *kind, path));
|
||||
}
|
||||
out.push_output("");
|
||||
out.push_output(&format!("Total Xref(s): {}", rows.len()));
|
||||
}
|
||||
|
||||
/// Checks the file, reports it the way the reference does, then asks
|
||||
/// for whatever is left on-screen (or commits straight away).
|
||||
pub(in crate::app) fn start_xref_attach(
|
||||
&mut self,
|
||||
i: usize,
|
||||
request: XrefAttachRequest,
|
||||
placement: XrefPlacement,
|
||||
label: &'static str,
|
||||
) {
|
||||
let name = path_to_block_name(&request.path);
|
||||
let full = crate::modules::insert::xattach::resolve_xref_store_path(&request.path, None);
|
||||
let host = self.tabs[i].current_path.clone();
|
||||
if let Some(host) = host.as_deref() {
|
||||
if crate::modules::insert::xattach::is_self_attach(host, &full, host.parent()) {
|
||||
self.command_line.push_error(
|
||||
"Error: Possible circular reference to current drawing. *Invalid*",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
let existing = existing_reference(&self.tabs[i].scene, &name);
|
||||
if let Some(existing) = existing.as_ref() {
|
||||
self.command_line
|
||||
.push_output(&format!("Xref \"{}\" has already been defined.", existing));
|
||||
self.command_line
|
||||
.push_output("Using existing definition.");
|
||||
} else {
|
||||
let verb = if request.overlay {
|
||||
format!("Overlay Xref \"{}\": {}", name, display_path(&full))
|
||||
} else {
|
||||
format!("Attach Xref \"{}\": {}", name, display_path(&full))
|
||||
};
|
||||
self.command_line.push_output(&verb);
|
||||
if !std::path::Path::new(&full).is_file() {
|
||||
let file = std::path::Path::new(&full.replace('\\', "/"))
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().into_owned())
|
||||
.unwrap_or(full.clone());
|
||||
self.command_line
|
||||
.push_output(&format!("\"{}\" cannot be found.", file));
|
||||
self.command_line.push_error("*Invalid*");
|
||||
return;
|
||||
}
|
||||
self.command_line
|
||||
.push_output(&format!("\"{}\" loaded.", name));
|
||||
}
|
||||
let command = XAttachCommand::new(request.clone(), placement, label);
|
||||
if let Some(entity) = command.immediate() {
|
||||
self.commit_xref_attach(i, request, entity);
|
||||
return;
|
||||
}
|
||||
let (wires, source_units) = self.xref_preview(i, existing.as_deref(), &full);
|
||||
let host_units = self.tabs[i].scene.document.header.insertion_units;
|
||||
let unit = crate::app::properties::insert_unit_scale(host_units, source_units).unwrap_or(1.0);
|
||||
let command = command.with_preview(wires, unit);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
}
|
||||
|
||||
/// The reference's geometry for the placement ghost, in its own units:
|
||||
/// an existing definition's content, or the file's model space.
|
||||
fn xref_preview(
|
||||
&self,
|
||||
i: usize,
|
||||
existing: Option<&str>,
|
||||
path: &str,
|
||||
) -> (Vec<crate::scene::model::wire_model::WireModel>, i16) {
|
||||
// ponytail: a huge reference skips the ghost; cap by entity count.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_PREVIEW_ENTITIES: usize = 20_000;
|
||||
let scene = &self.tabs[i].scene;
|
||||
if let Some(name) = existing {
|
||||
let units = scene
|
||||
.document
|
||||
.block_records
|
||||
.get(name)
|
||||
.map(|br| br.units)
|
||||
.unwrap_or(0);
|
||||
return (scene.block_preview_wires(name), units);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Ok(source) = crate::io::load_file(std::path::Path::new(path)) {
|
||||
let owner = source.header.model_space_block_handle;
|
||||
let entities: Vec<acadrust::EntityType> = source
|
||||
.entities()
|
||||
.filter(|e| e.common().owner_handle == owner)
|
||||
.filter(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
acadrust::EntityType::Block(_) | acadrust::EntityType::BlockEnd(_)
|
||||
)
|
||||
})
|
||||
.take(MAX_PREVIEW_ENTITIES + 1)
|
||||
.cloned()
|
||||
.collect();
|
||||
if entities.len() <= MAX_PREVIEW_ENTITIES {
|
||||
return (
|
||||
scene.wires_for_entities(&entities),
|
||||
source.header.insertion_units,
|
||||
);
|
||||
}
|
||||
}
|
||||
let _ = path;
|
||||
(Vec::new(), 0)
|
||||
}
|
||||
|
||||
/// Creates (or reuses) the definition and commits its INSERT — one undo
|
||||
/// step for both, so undo leaves no definition behind.
|
||||
pub(in crate::app) fn commit_xref_attach(
|
||||
&mut self,
|
||||
i: usize,
|
||||
request: XrefAttachRequest,
|
||||
mut entity: acadrust::EntityType,
|
||||
) {
|
||||
let pending = self.begin_undo(i, "XATTACH", 1, false);
|
||||
let host = self.tabs[i].current_path.clone();
|
||||
let name = prepare_xref_definition(&mut self.tabs[i].scene, &request, host.as_deref());
|
||||
if host.is_none() && request.path_type == Pathtype::Relative {
|
||||
self.tabs[i].xref_relative_on_save.insert(name.clone());
|
||||
}
|
||||
if let acadrust::EntityType::Insert(insert) = &mut entity {
|
||||
insert.block_name = name;
|
||||
}
|
||||
// Resolving merged the reference's layers and linetypes — mirror
|
||||
// them into the Layers panel and ribbon dropdowns now (#407).
|
||||
self.refresh_layer_panel();
|
||||
let _ = self.commit_entity_handle(entity);
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.clear_preview_wire();
|
||||
self.tabs[i].active_cmd = None;
|
||||
self.tabs[i].snap_result = None;
|
||||
self.restore_pre_cmd_tangent();
|
||||
if let Some(pending) = pending {
|
||||
self.commit_undo_delta(i, pending);
|
||||
}
|
||||
self.refresh_xref_manager();
|
||||
}
|
||||
|
||||
/// Opens the Attach External Reference dialog for `path`.
|
||||
pub(in crate::app) fn open_xref_attach_dialog(&mut self, path: std::path::PathBuf) {
|
||||
let i = self.active_tab;
|
||||
let existing: Vec<(String, String)> = self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.iter()
|
||||
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
.map(|br| (br.name.clone(), br.xref_path.clone()))
|
||||
.collect();
|
||||
let mut state = XrefAttachState::new(existing);
|
||||
self.load_xref_attach_file(&mut state, &path.to_string_lossy());
|
||||
self.xref_attach = Some(state);
|
||||
self.active_modal = Some(crate::app::ModalKind::XrefAttach);
|
||||
}
|
||||
|
||||
/// Fills the dialog from the chosen file: name, preview, block unit and
|
||||
/// the paths shown under Details.
|
||||
fn load_xref_attach_file(&self, state: &mut XrefAttachState, path: &str) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let i = self.active_tab;
|
||||
state.path = path.to_string();
|
||||
state.name = path_to_block_name(path);
|
||||
state.found_in = display_path(path);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
state.preview = crate::io::thumbnail::read_handle(std::path::Path::new(path));
|
||||
let host_units = self.tabs[i].scene.document.header.insertion_units;
|
||||
let source_units = crate::io::load_file(std::path::Path::new(path))
|
||||
.map(|doc| doc.header.insertion_units)
|
||||
.unwrap_or(0);
|
||||
state.unit_label = crate::t!(crate::app::properties::insunits_name(source_units))
|
||||
.into_owned();
|
||||
let factor =
|
||||
crate::app::properties::insert_unit_scale(host_units, source_units).unwrap_or(1.0);
|
||||
state.unit_factor = crate::app::properties::format_unit_factor(factor);
|
||||
}
|
||||
self.refresh_xref_attach_saved_path(state);
|
||||
}
|
||||
|
||||
fn refresh_xref_attach_saved_path(&self, state: &mut XrefAttachState) {
|
||||
let host = self.tabs[self.active_tab].current_path.clone();
|
||||
let request = XrefAttachRequest {
|
||||
path: state.path.clone(),
|
||||
overlay: state.overlay,
|
||||
path_type: state.path_type.0,
|
||||
};
|
||||
state.saved_path = display_path(&stored_path(&request, host.as_deref()));
|
||||
}
|
||||
|
||||
/// Messages of the ATTACH picker and the dialog.
|
||||
pub(in crate::app) fn update_xref_attach(&mut self, message: Message) -> Task<Message> {
|
||||
match message {
|
||||
Message::AttachPick => Task::perform(
|
||||
async {
|
||||
let handle = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Select Reference File").as_ref())
|
||||
.add_filter(
|
||||
crate::t!("All Reference Files").as_ref(),
|
||||
&[
|
||||
"dwg", "dxf", "pdf", "png", "jpg", "jpeg", "bmp", "tif", "tiff",
|
||||
],
|
||||
)
|
||||
.add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"])
|
||||
.add_filter(crate::t!("PDF Files").as_ref(), &["pdf"])
|
||||
.add_filter(
|
||||
crate::t!("Images").as_ref(),
|
||||
&["png", "jpg", "jpeg", "bmp", "tif", "tiff"],
|
||||
)
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Ok(crate::sys::handle_path(&h)),
|
||||
None => Err("Cancelled".to_string()),
|
||||
}
|
||||
},
|
||||
Message::AttachPickResult,
|
||||
),
|
||||
Message::AttachPickResult(Ok(path)) => {
|
||||
let ext = path
|
||||
.extension()
|
||||
.map(|e| e.to_string_lossy().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
match ext.as_str() {
|
||||
"pdf" => match std::fs::read(&path) {
|
||||
Ok(bytes) => self.update(Message::PdfAttachPickResult(Ok((
|
||||
path,
|
||||
std::sync::Arc::new(bytes),
|
||||
)))),
|
||||
Err(e) => self.update(Message::PdfAttachPickResult(Err(e.to_string()))),
|
||||
},
|
||||
"png" | "jpg" | "jpeg" | "bmp" | "tif" | "tiff" => {
|
||||
let result = image::image_dimensions(&path)
|
||||
.map(|(w, h)| (path.clone(), w, h))
|
||||
.map_err(|e| e.to_string());
|
||||
self.update(Message::ImagePickResult(result))
|
||||
}
|
||||
_ => {
|
||||
self.open_xref_attach_dialog(path);
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::AttachPickResult(Err(e)) => {
|
||||
if e != "Cancelled" {
|
||||
self.command_line
|
||||
.push_error(format!("ATTACH: {e}").as_ref());
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefAttachBrowseResult(Ok(path)) => {
|
||||
if let Some(mut state) = self.xref_attach.take() {
|
||||
self.load_xref_attach_file(&mut state, &path.to_string_lossy());
|
||||
self.xref_attach = Some(state);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::XrefAttachBrowseResult(Err(_)) => Task::none(),
|
||||
Message::XrefAttach(edit) => self.edit_xref_attach(edit),
|
||||
_ => Task::none(),
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_xref_attach(&mut self, edit: XrefAttachMsg) -> Task<Message> {
|
||||
let Some(mut state) = self.xref_attach.take() else {
|
||||
return Task::none();
|
||||
};
|
||||
let mut task = Task::none();
|
||||
match edit {
|
||||
XrefAttachMsg::Name(name) => {
|
||||
if let Some((_, saved)) = state
|
||||
.existing
|
||||
.iter()
|
||||
.find(|(n, _)| n.eq_ignore_ascii_case(&name))
|
||||
.cloned()
|
||||
{
|
||||
let host = self.tabs[self.active_tab].current_path.clone();
|
||||
let base = host.as_deref().and_then(|h| h.parent());
|
||||
let full = crate::modules::insert::xattach::resolve_xref_store_path(&saved, base);
|
||||
self.load_xref_attach_file(&mut state, &full);
|
||||
}
|
||||
}
|
||||
XrefAttachMsg::Browse => {
|
||||
task = Task::perform(
|
||||
async {
|
||||
let handle = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Select Reference File").as_ref())
|
||||
.add_filter(crate::t!("Drawing").as_ref(), &["dwg", "dxf"])
|
||||
.pick_file()
|
||||
.await;
|
||||
match handle {
|
||||
Some(h) => Ok(crate::sys::handle_path(&h)),
|
||||
None => Err("Cancelled".to_string()),
|
||||
}
|
||||
},
|
||||
Message::XrefAttachBrowseResult,
|
||||
);
|
||||
}
|
||||
XrefAttachMsg::Overlay(overlay) => state.overlay = overlay,
|
||||
XrefAttachMsg::ScaleOnScreen(on) => state.scale_on_screen = on,
|
||||
XrefAttachMsg::Scale(axis, value) => {
|
||||
if axis == 0 && state.uniform {
|
||||
state.scale = [value.clone(), value.clone(), value];
|
||||
} else if let Some(slot) = state.scale.get_mut(axis) {
|
||||
*slot = value;
|
||||
}
|
||||
}
|
||||
XrefAttachMsg::Uniform(on) => {
|
||||
state.uniform = on;
|
||||
if on {
|
||||
let x = state.scale[0].clone();
|
||||
state.scale = [x.clone(), x.clone(), x];
|
||||
}
|
||||
}
|
||||
XrefAttachMsg::InsertOnScreen(on) => state.insert_on_screen = on,
|
||||
XrefAttachMsg::Insert(axis, value) => {
|
||||
if let Some(slot) = state.insert.get_mut(axis) {
|
||||
*slot = value;
|
||||
}
|
||||
}
|
||||
XrefAttachMsg::PathType(choice) => {
|
||||
state.path_type = choice;
|
||||
self.refresh_xref_attach_saved_path(&mut state);
|
||||
}
|
||||
XrefAttachMsg::RotationOnScreen(on) => state.rotation_on_screen = on,
|
||||
XrefAttachMsg::Rotation(value) => state.rotation = value,
|
||||
XrefAttachMsg::Details(on) => state.details = on,
|
||||
XrefAttachMsg::Help => {
|
||||
self.command_line.push_info(
|
||||
crate::t!("Inserts references to external files such as other drawings, raster images, and underlays.")
|
||||
.as_ref(),
|
||||
);
|
||||
}
|
||||
XrefAttachMsg::Apply => {
|
||||
let placement = XrefPlacement {
|
||||
insert: (!state.insert_on_screen)
|
||||
.then(|| state.insert_point())
|
||||
.flatten(),
|
||||
scale: (!state.scale_on_screen).then(|| state.scale_values()).flatten(),
|
||||
rotation: (!state.rotation_on_screen)
|
||||
.then(|| crate::entities::common::parse_typed_angle(&state.rotation))
|
||||
.flatten()
|
||||
.map(f64::to_degrees),
|
||||
};
|
||||
let invalid = (!state.insert_on_screen && placement.insert.is_none())
|
||||
|| (!state.scale_on_screen
|
||||
&& placement
|
||||
.scale
|
||||
.is_none_or(|s| s.iter().any(|v| *v == 0.0)))
|
||||
|| (!state.rotation_on_screen && placement.rotation.is_none());
|
||||
if invalid || state.path.is_empty() {
|
||||
self.command_line
|
||||
.push_error(crate::t!("Invalid input.").as_ref());
|
||||
self.xref_attach = Some(state);
|
||||
return Task::none();
|
||||
}
|
||||
let request = XrefAttachRequest {
|
||||
path: state.path.clone(),
|
||||
overlay: state.overlay,
|
||||
path_type: state.path_type.0,
|
||||
};
|
||||
self.active_modal = None;
|
||||
let i = self.active_tab;
|
||||
self.start_xref_attach(i, request, placement, "XATTACH");
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
self.xref_attach = Some(state);
|
||||
task
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ pub(super) const NAMES: &[&str] = &[
|
||||
"pdf_page_select",
|
||||
"ribbon_tab",
|
||||
"ribbon_dropdown",
|
||||
"dialog_ok",
|
||||
"close_document",
|
||||
"toggle_properties",
|
||||
"toggle_layers",
|
||||
@@ -705,6 +706,14 @@ impl OpenCADStudio {
|
||||
_ => return Err(failure("no_dialog", "No PDF dialog is open")),
|
||||
})
|
||||
}
|
||||
// The open dialog's OK button.
|
||||
"dialog_ok" => match self.active_modal {
|
||||
Some(crate::app::ModalKind::XrefAttach) => Message::XrefAttach(
|
||||
crate::ui::window::xref_attach::XrefAttachMsg::Apply,
|
||||
),
|
||||
Some(crate::app::ModalKind::BlockDefinition) => Message::BlockDefApply,
|
||||
_ => return Err(failure("no_dialog", "No dialog with an OK button is open")),
|
||||
},
|
||||
"close_document" => Message::TabClose(self.active_tab),
|
||||
"toggle_properties" => Message::ToggleProperties,
|
||||
"toggle_layers" => Message::ToggleLayers,
|
||||
|
||||
+40
-3
@@ -426,8 +426,10 @@ impl OpenCADStudio {
|
||||
pub(super) fn control_state(&self) -> Value {
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
let command = tab.active_cmd.as_deref().map(active_command_metadata);
|
||||
let missing_fonts = crate::io::font_repo::missing_shx_fonts(&tab.scene.document);
|
||||
json!({"ok":true,"protocol":1,"session_id":session_id(),"version":env!("OCS_APP_VERSION"),
|
||||
"mode":if self.main_window.is_some(){"gui"}else{"headless"},"enabled":self.control.enabled,
|
||||
"read_only":self.read_only,"missing_fonts":missing_fonts,
|
||||
"document_id":tab.id,"revision":tab.edit_revision,"geometry_revision":tab.scene.geometry_epoch,"camera_revision":tab.scene.camera_generation,
|
||||
"hand_seed":format!("{:X}",tab.scene.document.header.handle_seed.max(tab.scene.document.next_handle())),
|
||||
"plugins":plugin_ids(),
|
||||
@@ -442,7 +444,7 @@ impl OpenCADStudio {
|
||||
"mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})),
|
||||
"text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial,
|
||||
"operation":self.control.pending.as_ref().map(|p| &p.id),
|
||||
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","user_select","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
|
||||
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","user_select","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query","audit","verified_save","explicit_save_version"]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -467,6 +469,7 @@ impl OpenCADStudio {
|
||||
| "history"
|
||||
| "xdata_get"
|
||||
| "get_selection"
|
||||
| "audit"
|
||||
);
|
||||
if !query && !self.control.enabled {
|
||||
return (
|
||||
@@ -972,8 +975,36 @@ impl OpenCADStudio {
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| self.tabs[i].current_path.clone())
|
||||
.ok_or_else(|| failure("path_required", "Supply a save path"))?;
|
||||
let default_is_dxf = crate::io::source_is_dxf(
|
||||
self.tabs[i].current_path.as_deref(),
|
||||
&self.tabs[i].scene.document,
|
||||
);
|
||||
let (version, is_dxf) = super::automation::requested_save_target(
|
||||
req,
|
||||
self.tabs[i].scene.document.version,
|
||||
default_is_dxf,
|
||||
Some(&path),
|
||||
)
|
||||
.map_err(|error| failure("invalid_target", error))?;
|
||||
let dropped = crate::io::dropped_on_save_count(
|
||||
&self.tabs[i].scene.document,
|
||||
version,
|
||||
is_dxf,
|
||||
);
|
||||
if dropped > 0 && req["allow_lossy"].as_bool() != Some(true) {
|
||||
return Err(failure(
|
||||
"lossy_conversion_not_acknowledged",
|
||||
format!(
|
||||
"Conversion would drop {dropped} unsupported record(s); set allow_lossy=true to acknowledge"
|
||||
),
|
||||
));
|
||||
}
|
||||
self.set_control_result(json!({
|
||||
"saved":path,"target_format":if is_dxf { "dxf" } else { "dwg" },
|
||||
"target_version":format!("{version:?}"),"dropped_on_save":dropped,
|
||||
}));
|
||||
if self.main_window.is_none() {
|
||||
self.save_tab_synchronously_protected(i, path, true)
|
||||
self.save_tab_synchronously_protected_as(i, path, version, true)
|
||||
.map_err(|e| failure("save_failed", e))?;
|
||||
Task::none()
|
||||
} else {
|
||||
@@ -981,7 +1012,7 @@ impl OpenCADStudio {
|
||||
self.queue_native_save(
|
||||
i,
|
||||
path,
|
||||
acadrust::DxfVersion::AC1032,
|
||||
version,
|
||||
super::SavePurpose::SaveAs,
|
||||
super::SaveContinuation::None,
|
||||
true,
|
||||
@@ -989,6 +1020,12 @@ impl OpenCADStudio {
|
||||
)
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
"save_verified" => {
|
||||
let result = self.save_verified_request(req)?;
|
||||
self.set_control_result(result);
|
||||
Task::none()
|
||||
}
|
||||
"capture" => {
|
||||
let window = self
|
||||
.main_window
|
||||
|
||||
@@ -231,6 +231,9 @@ pub(super) struct DocumentTab {
|
||||
/// Session set of unloaded reference keys (Task 8b). Owns the set that
|
||||
/// `collect_entries` takes as `unloaded`, so CLI and palette agree.
|
||||
pub(super) xref_unloaded: crate::io::xref_model::UnloadSet,
|
||||
/// References attached with a relative path while the drawing had no
|
||||
/// file yet: stored full until the first save, then made relative.
|
||||
pub(super) xref_relative_on_save: rustc_hash::FxHashSet<String>,
|
||||
/// Load-time mtimes per reference key (Task 8b). Written on every
|
||||
/// palette refresh; `Stale` is detectable from the second refresh on.
|
||||
pub(super) xref_stat_cache: crate::io::xref_model::RefStatCache,
|
||||
@@ -650,6 +653,7 @@ impl DocumentTab {
|
||||
active_mleader_style: "Standard".to_string(),
|
||||
last_synced_camera_gen: 0,
|
||||
xref_unloaded: crate::io::xref_model::UnloadSet::default(),
|
||||
xref_relative_on_save: rustc_hash::FxHashSet::default(),
|
||||
xref_stat_cache: crate::io::xref_model::RefStatCache::default(),
|
||||
xref_missing: 0,
|
||||
is_start: false,
|
||||
|
||||
@@ -500,6 +500,10 @@ pub(super) struct OpenCADStudio {
|
||||
pdf_import_file: Option<crate::ui::window::pdf_dialogs::PdfImportFileState>,
|
||||
/// The dialog Options was opened from; it comes back when Options closes.
|
||||
options_parent: Option<ModalKind>,
|
||||
/// Working copy of the Attach External Reference dialog; None while closed.
|
||||
xref_attach: Option<crate::ui::window::xref_attach::XrefAttachState>,
|
||||
/// Working copy of the Write Block (WBLOCK) dialog; `None` while it is closed.
|
||||
wblock: Option<crate::ui::window::wblock::WblockState>,
|
||||
/// Working copy of the structured feature-control-frame editor.
|
||||
geometric_tolerance: Option<crate::ui::window::geometric_tolerance::State>,
|
||||
/// PICKDRAG (#226): false (default) = press-drag lassoes; true =
|
||||
@@ -1265,6 +1269,15 @@ pub(super) struct OpenCADStudio {
|
||||
/// Missing SHX fonts of the last opened drawing, offered for download
|
||||
/// from the community repository (see `crate::io::font_repo`).
|
||||
missing_fonts: Option<Vec<String>>,
|
||||
/// Drawing that produced `missing_fonts`; the active tab may change while
|
||||
/// a download is in flight.
|
||||
missing_fonts_path: Option<PathBuf>,
|
||||
/// Prevent duplicate download tasks and keep modal contents visible while
|
||||
/// the background request is running.
|
||||
missing_fonts_downloading: bool,
|
||||
/// Fonts skipped or unavailable during this process lifetime. Reopening a
|
||||
/// drawing must not repeatedly nag for the same unresolved file.
|
||||
suppressed_missing_fonts: rustc_hash::FxHashSet<String>,
|
||||
/// Drawings handed to us by other launches while `opening` was busy.
|
||||
/// `opening` is a single slot that a second `OpenPathPicked` would
|
||||
/// overwrite, and `on_file_opened` drops any result arriving once it is
|
||||
@@ -1882,6 +1895,8 @@ pub enum ModalKind {
|
||||
UnderlayLayers,
|
||||
PdfImportSettings,
|
||||
PdfImportFile,
|
||||
XrefAttach,
|
||||
WriteBlock,
|
||||
GeometricTolerance,
|
||||
DraftingSettings,
|
||||
AutoConstrainSettings,
|
||||
@@ -2879,6 +2894,24 @@ pub enum Message {
|
||||
BlockDefConfirmRedefine(bool),
|
||||
BlockDefDismissError,
|
||||
BlockDefHelp,
|
||||
/// Write Block (WBLOCK) dialog messages
|
||||
WblockSourceMode(crate::ui::window::wblock::WblockSourceMode),
|
||||
WblockBlockName(String),
|
||||
WblockBlockSelect(String),
|
||||
WblockPickPoint,
|
||||
WblockBaseX(String),
|
||||
WblockBaseY(String),
|
||||
WblockBaseZ(String),
|
||||
WblockSelectObjects,
|
||||
WblockQuickSelect,
|
||||
WblockObjectMode(crate::ui::window::wblock::WblockObjectMode),
|
||||
WblockFilePath(String),
|
||||
WblockBrowsePath,
|
||||
WblockBrowsePathResult(Option<std::path::PathBuf>),
|
||||
WblockUnit(i16),
|
||||
WblockApply,
|
||||
WblockDismissError,
|
||||
WblockHelp,
|
||||
/// One structured feature-control-frame field changed.
|
||||
ToleranceDialogField(crate::ui::window::geometric_tolerance::Field),
|
||||
/// One structured feature-control-frame option changed.
|
||||
@@ -3798,6 +3831,14 @@ pub enum Message {
|
||||
XAttachPick,
|
||||
/// Result of the XATTACH file picker.
|
||||
XAttachPickResult(Result<std::path::PathBuf, String>),
|
||||
/// ATTACH: pick a drawing, image or PDF to reference.
|
||||
AttachPick,
|
||||
/// Result of the ATTACH file picker.
|
||||
AttachPickResult(Result<std::path::PathBuf, String>),
|
||||
/// An edit in the Attach External Reference dialog.
|
||||
XrefAttach(crate::ui::window::xref_attach::XrefAttachMsg),
|
||||
/// Result of the dialog's Browse picker.
|
||||
XrefAttachBrowseResult(Result<std::path::PathBuf, String>),
|
||||
// ── WBLOCK ────────────────────────────────────────────────────────────
|
||||
/// Trigger the WBLOCK save dialog for `block_name` (or `*` = selection).
|
||||
WblockSave(String),
|
||||
@@ -3965,6 +4006,8 @@ impl OpenCADStudio {
|
||||
pdf_import_settings: None,
|
||||
pdf_import_file: None,
|
||||
options_parent: None,
|
||||
xref_attach: None,
|
||||
wblock: None,
|
||||
geometric_tolerance: None,
|
||||
pick_drag_rect: false,
|
||||
perf_hud: false,
|
||||
@@ -4202,6 +4245,9 @@ impl OpenCADStudio {
|
||||
open_job_serial: 0,
|
||||
recovery_report: None,
|
||||
missing_fonts: None,
|
||||
missing_fonts_path: None,
|
||||
missing_fonts_downloading: false,
|
||||
suppressed_missing_fonts: rustc_hash::FxHashSet::default(),
|
||||
pending_opens: std::collections::VecDeque::new(),
|
||||
active_interaction_index: None,
|
||||
queued_interaction_indices: std::collections::VecDeque::new(),
|
||||
|
||||
+245
-3
@@ -2710,6 +2710,13 @@ impl crate::command::CadCommand for PluginInteractiveAdapter {
|
||||
.map(plugin_step_to_result)
|
||||
.unwrap_or(crate::command::CmdResult::Cancel)
|
||||
}
|
||||
fn on_preview_wires(&mut self, pt: glam::DVec3) -> Vec<crate::scene::model::wire_model::WireModel> {
|
||||
let raw_wires = crate::plugin::guard("InteractiveCommand::on_cursor_move", || {
|
||||
self.inner.on_cursor_move([pt.x as f64, pt.y as f64, pt.z as f64])
|
||||
})
|
||||
.unwrap_or_default();
|
||||
convert_preview_wires(raw_wires)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bridges an out-of-process plugin's interactive command to the host's
|
||||
@@ -2777,7 +2784,12 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter {
|
||||
.interactive_event(self.command_id, InteractiveEvent::Point([pt.x, pt.y, pt.z]))
|
||||
.map(plugin_step_to_result)
|
||||
.unwrap_or(crate::command::CmdResult::Cancel);
|
||||
if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) {
|
||||
if matches!(
|
||||
result,
|
||||
crate::command::CmdResult::CommitAndExit(_)
|
||||
| crate::command::CmdResult::CommitEntitiesAndExit(_)
|
||||
| crate::command::CmdResult::Cancel
|
||||
) {
|
||||
self.is_done = true;
|
||||
}
|
||||
self.refresh();
|
||||
@@ -2790,7 +2802,12 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter {
|
||||
.interactive_event(self.command_id, InteractiveEvent::Enter)
|
||||
.map(plugin_step_to_result)
|
||||
.unwrap_or(crate::command::CmdResult::Cancel);
|
||||
if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) {
|
||||
if matches!(
|
||||
result,
|
||||
crate::command::CmdResult::CommitAndExit(_)
|
||||
| crate::command::CmdResult::CommitEntitiesAndExit(_)
|
||||
| crate::command::CmdResult::Cancel
|
||||
) {
|
||||
self.is_done = true;
|
||||
}
|
||||
self.refresh();
|
||||
@@ -2816,12 +2833,101 @@ impl crate::command::CadCommand for PluginProcessInteractiveAdapter {
|
||||
)
|
||||
.map(plugin_step_to_result)
|
||||
.unwrap_or(crate::command::CmdResult::Cancel);
|
||||
if matches!(result, crate::command::CmdResult::CommitAndExit(_) | crate::command::CmdResult::Cancel) {
|
||||
if matches!(
|
||||
result,
|
||||
crate::command::CmdResult::CommitAndExit(_)
|
||||
| crate::command::CmdResult::CommitEntitiesAndExit(_)
|
||||
| crate::command::CmdResult::Cancel
|
||||
) {
|
||||
self.is_done = true;
|
||||
}
|
||||
self.refresh();
|
||||
result
|
||||
}
|
||||
fn on_preview_wires(&mut self, pt: glam::DVec3) -> Vec<crate::scene::model::wire_model::WireModel> {
|
||||
let raw_wires = self
|
||||
.process
|
||||
.on_cursor_move(self.command_id, [pt.x as f64, pt.y as f64, pt.z as f64])
|
||||
.unwrap_or_default();
|
||||
convert_preview_wires(raw_wires)
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_preview_wires(
|
||||
raw_wires: Vec<ocs_plugin_api::host::PreviewWire>,
|
||||
) -> Vec<crate::scene::model::wire_model::WireModel> {
|
||||
use ocs_plugin_api::host::PreviewWire;
|
||||
use crate::scene::model::wire_model::{TangentGeom, WireModel};
|
||||
|
||||
raw_wires
|
||||
.into_iter()
|
||||
.filter_map(|w| match w {
|
||||
PreviewWire::Polyline { points, color } => {
|
||||
if points.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let c = color.unwrap_or(WireModel::CYAN);
|
||||
Some(WireModel::solid_f64("rubber_band".into(), points, c, false))
|
||||
}
|
||||
PreviewWire::Circle { center, radius, color } => {
|
||||
if radius <= 0.0 || !radius.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let c = color.unwrap_or(WireModel::CYAN);
|
||||
// Two opposite diameter points ensure OCS's viewport frame-cache
|
||||
// (render_signature) detects cursor motion and triggers 60-120 FPS
|
||||
// redraws without requiring polar snap or artificial guide lines.
|
||||
let points = vec![
|
||||
[center[0] - radius, center[1], center[2]],
|
||||
[center[0] + radius, center[1], center[2]],
|
||||
];
|
||||
let mut wire = WireModel::solid_f64("rubber_band".into(), points, c, false);
|
||||
wire.tangent_geoms.push(TangentGeom::PlanarCircle {
|
||||
center,
|
||||
axis_x: [1.0, 0.0, 0.0],
|
||||
axis_y: [0.0, 1.0, 0.0],
|
||||
radius,
|
||||
});
|
||||
Some(wire)
|
||||
}
|
||||
PreviewWire::Arc {
|
||||
center,
|
||||
radius,
|
||||
start_angle_rad,
|
||||
end_angle_rad,
|
||||
color,
|
||||
} => {
|
||||
if radius <= 0.0 || !radius.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let c = color.unwrap_or(WireModel::CYAN);
|
||||
// Start and end points along the circumference ensure OCS's viewport
|
||||
// frame-cache (render_signature) detects cursor motion and triggers
|
||||
// 60-120 FPS redraws without requiring polar snap or artificial guide lines.
|
||||
let p_start = [
|
||||
center[0] + radius * start_angle_rad.cos(),
|
||||
center[1] + radius * start_angle_rad.sin(),
|
||||
center[2],
|
||||
];
|
||||
let p_end = [
|
||||
center[0] + radius * end_angle_rad.cos(),
|
||||
center[1] + radius * end_angle_rad.sin(),
|
||||
center[2],
|
||||
];
|
||||
let points = vec![p_start, p_end];
|
||||
let mut wire = WireModel::solid_f64("rubber_band".into(), points, c, false);
|
||||
wire.tangent_geoms.push(TangentGeom::Arc {
|
||||
center,
|
||||
axis_x: [1.0, 0.0, 0.0],
|
||||
axis_y: [0.0, 1.0, 0.0],
|
||||
radius,
|
||||
start_angle: start_angle_rad,
|
||||
end_angle: end_angle_rad,
|
||||
});
|
||||
Some(wire)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::command::CmdResult {
|
||||
@@ -2830,7 +2936,9 @@ fn plugin_step_to_result(step: ocs_plugin_api::host::CommandStep) -> crate::comm
|
||||
match step {
|
||||
CommandStep::NeedPoint => CmdResult::NeedPoint,
|
||||
CommandStep::Commit(e) => CmdResult::CommitEntity(e),
|
||||
CommandStep::CommitMany(es) => CmdResult::CommitEntities(es),
|
||||
CommandStep::CommitAndEnd(e) => CmdResult::CommitAndExit(e),
|
||||
CommandStep::CommitManyAndEnd(es) => CmdResult::CommitEntitiesAndExit(es),
|
||||
CommandStep::Done | CommandStep::Cancel => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
@@ -9226,6 +9334,140 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0)))
|
||||
);
|
||||
}
|
||||
|
||||
/// A plugin command that previews a rubber-band line to the cursor.
|
||||
struct PreviewLine {
|
||||
start: [f64; 3],
|
||||
color: Option<[f32; 4]>,
|
||||
}
|
||||
impl ocs_plugin_api::host::InteractiveCommand for PreviewLine {
|
||||
fn prompt(&self) -> String {
|
||||
"Pick endpoint".to_string()
|
||||
}
|
||||
fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep {
|
||||
ocs_plugin_api::host::CommandStep::Done
|
||||
}
|
||||
fn on_cursor_move(&mut self, pt: [f64; 3]) -> Vec<ocs_plugin_api::host::PreviewWire> {
|
||||
vec![ocs_plugin_api::host::PreviewWire::line(self.start, pt, self.color)]
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin command that previews an analytical circle.
|
||||
struct PreviewCircle {
|
||||
center: [f64; 3],
|
||||
color: Option<[f32; 4]>,
|
||||
}
|
||||
impl ocs_plugin_api::host::InteractiveCommand for PreviewCircle {
|
||||
fn prompt(&self) -> String {
|
||||
"Pick radius".to_string()
|
||||
}
|
||||
fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep {
|
||||
ocs_plugin_api::host::CommandStep::Done
|
||||
}
|
||||
fn on_cursor_move(&mut self, pt: [f64; 3]) -> Vec<ocs_plugin_api::host::PreviewWire> {
|
||||
let dx = pt[0] - self.center[0];
|
||||
let dy = pt[1] - self.center[1];
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
vec![ocs_plugin_api::host::PreviewWire::circle(self.center, r, self.color)]
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin command that previews an analytical arc.
|
||||
struct PreviewArc {
|
||||
center: [f64; 3],
|
||||
color: Option<[f32; 4]>,
|
||||
}
|
||||
impl ocs_plugin_api::host::InteractiveCommand for PreviewArc {
|
||||
fn prompt(&self) -> String {
|
||||
"Pick endpoint".to_string()
|
||||
}
|
||||
fn on_point(&mut self, _pt: [f64; 3]) -> ocs_plugin_api::host::CommandStep {
|
||||
ocs_plugin_api::host::CommandStep::Done
|
||||
}
|
||||
fn on_cursor_move(&mut self, pt: [f64; 3]) -> Vec<ocs_plugin_api::host::PreviewWire> {
|
||||
let dx = pt[0] - self.center[0];
|
||||
let dy = pt[1] - self.center[1];
|
||||
let r = (dx * dx + dy * dy).sqrt();
|
||||
vec![ocs_plugin_api::host::PreviewWire::arc(
|
||||
self.center,
|
||||
r,
|
||||
0.0,
|
||||
std::f64::consts::PI,
|
||||
self.color,
|
||||
)]
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_cursor_move_preview_wires() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
|
||||
// 1. Polyline default color (None -> CYAN)
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
host.start_interactive(Box::new(PreviewLine { start: [0.0, 0.0, 0.0], color: None }));
|
||||
}
|
||||
assert!(app.tabs[0].active_cmd.is_some());
|
||||
let cmd = app.tabs[0].active_cmd.as_mut().unwrap();
|
||||
let wires = cmd.on_preview_wires(glam::DVec3::new(10.0, 20.0, 0.0));
|
||||
assert_eq!(wires.len(), 1);
|
||||
assert_eq!(wires[0].name, "rubber_band");
|
||||
assert_eq!(wires[0].color, crate::scene::model::wire_model::WireModel::CYAN);
|
||||
|
||||
// 2. Polyline custom color ([1.0, 0.0, 0.0, 1.0])
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
host.start_interactive(Box::new(PreviewLine { start: [0.0, 0.0, 0.0], color: Some([1.0, 0.0, 0.0, 1.0]) }));
|
||||
}
|
||||
let cmd2 = app.tabs[0].active_cmd.as_mut().unwrap();
|
||||
let wires2 = cmd2.on_preview_wires(glam::DVec3::new(10.0, 20.0, 0.0));
|
||||
assert_eq!(wires2.len(), 1);
|
||||
assert_eq!(wires2[0].color, [1.0, 0.0, 0.0, 1.0]);
|
||||
|
||||
// 3. Analytical Circle with custom color
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
host.start_interactive(Box::new(PreviewCircle {
|
||||
center: [5.0, 5.0, 0.0],
|
||||
color: Some([0.0, 1.0, 0.0, 1.0]),
|
||||
}));
|
||||
}
|
||||
let cmd3 = app.tabs[0].active_cmd.as_mut().unwrap();
|
||||
let circle_wires = cmd3.on_preview_wires(glam::DVec3::new(15.0, 5.0, 0.0));
|
||||
assert_eq!(circle_wires.len(), 1);
|
||||
assert_eq!(circle_wires[0].points.len(), 2);
|
||||
assert_eq!(circle_wires[0].color, [0.0, 1.0, 0.0, 1.0]);
|
||||
assert_eq!(circle_wires[0].tangent_geoms.len(), 1);
|
||||
assert!(matches!(
|
||||
circle_wires[0].tangent_geoms[0],
|
||||
crate::scene::model::wire_model::TangentGeom::PlanarCircle { radius, .. } if (radius - 10.0).abs() < 1e-6
|
||||
));
|
||||
let instances = crate::scene::pipeline::circle_gpu::extract_circle_instances(&circle_wires[0], 0.0);
|
||||
assert!(instances.is_some());
|
||||
assert_eq!(instances.unwrap().len(), 1);
|
||||
|
||||
// 4. Analytical Arc
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
host.start_interactive(Box::new(PreviewArc {
|
||||
center: [0.0, 0.0, 0.0],
|
||||
color: None,
|
||||
}));
|
||||
}
|
||||
let cmd4 = app.tabs[0].active_cmd.as_mut().unwrap();
|
||||
let arc_wires = cmd4.on_preview_wires(glam::DVec3::new(10.0, 0.0, 0.0));
|
||||
assert_eq!(arc_wires.len(), 1);
|
||||
assert_eq!(arc_wires[0].points.len(), 2);
|
||||
assert_eq!(arc_wires[0].tangent_geoms.len(), 1);
|
||||
assert!(matches!(
|
||||
arc_wires[0].tangent_geoms[0],
|
||||
crate::scene::model::wire_model::TangentGeom::Arc { radius, .. } if (radius - 10.0).abs() < 1e-6
|
||||
));
|
||||
let arc_instances = crate::scene::pipeline::circle_gpu::extract_circle_instances(&arc_wires[0], 0.0);
|
||||
assert!(arc_instances.is_some());
|
||||
assert_eq!(arc_instances.unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// A plugin command that picks an existing object, then marks it.
|
||||
struct PickThenMark;
|
||||
impl ocs_plugin_api::host::InteractiveCommand for PickThenMark {
|
||||
|
||||
+85
-3
@@ -1478,6 +1478,25 @@ impl OpenCADStudio {
|
||||
},
|
||||
);
|
||||
}
|
||||
if let Some(br) = doc
|
||||
.block_records
|
||||
.get(&ins.block_name)
|
||||
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
|
||||
{
|
||||
let base_dir = self.tabs[i]
|
||||
.current_path
|
||||
.as_deref()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.to_path_buf())
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."));
|
||||
let overrides = crate::io::xref::layer_overrides(
|
||||
doc,
|
||||
&br.name,
|
||||
&br.xref_path,
|
||||
&base_dir,
|
||||
);
|
||||
xref_rows(&mut sections, ins, &br.xref_path, factor, overrides);
|
||||
}
|
||||
}
|
||||
// Underlay: name, page, path and size from the definition.
|
||||
acadrust::EntityType::Underlay(ul) => {
|
||||
@@ -3713,6 +3732,69 @@ fn merge_prop_value(
|
||||
/// Set the first property row matching `field` (across all sections) to a
|
||||
/// read-only `value`. No-op when the field is absent. Used to fill the
|
||||
/// doc-dependent placeholder rows the entity builders leave empty.
|
||||
/// An external reference's rows as the reference application lists them:
|
||||
/// Insertion point X/Y/Z, Scale X/Y/Z without the unit conversion the INSERT
|
||||
/// carries (no uniform-scale or Annotative rows), Saved Path after the name
|
||||
/// and Layer property overrides last.
|
||||
fn xref_rows(
|
||||
sections: &mut [crate::scene::model::object::PropSection],
|
||||
ins: &acadrust::entities::Insert,
|
||||
saved_path: &str,
|
||||
factor: f64,
|
||||
overrides: bool,
|
||||
) {
|
||||
use crate::entities::common::{edit_prop, ro_prop};
|
||||
let factor = if factor.abs() > 0.0 { factor } else { 1.0 };
|
||||
for section in sections.iter_mut() {
|
||||
for prop in section.props.iter_mut() {
|
||||
let label = match prop.field {
|
||||
"ins_x" => "Insertion point X",
|
||||
"ins_y" => "Insertion point Y",
|
||||
"ins_z" => "Insertion point Z",
|
||||
_ => continue,
|
||||
};
|
||||
prop.label = t!(label).into_owned();
|
||||
}
|
||||
let scale_at = section.props.iter().position(|p| {
|
||||
matches!(p.field, "x_scale" | "y_scale" | "z_scale" | "u_scale" | "ins_uniform")
|
||||
});
|
||||
section.props.retain(|p| {
|
||||
!matches!(
|
||||
p.field,
|
||||
"x_scale" | "y_scale" | "z_scale" | "u_scale" | "ins_uniform" | "annotative"
|
||||
)
|
||||
});
|
||||
if let Some(at) = scale_at {
|
||||
let at = at.min(section.props.len());
|
||||
section.props.splice(
|
||||
at..at,
|
||||
[
|
||||
edit_prop(t!("Scale X").as_ref(), "xref_x_scale", ins.x_scale() / factor),
|
||||
edit_prop(t!("Scale Y").as_ref(), "xref_y_scale", ins.y_scale() / factor),
|
||||
edit_prop(t!("Scale Z").as_ref(), "xref_z_scale", ins.z_scale() / factor),
|
||||
],
|
||||
);
|
||||
}
|
||||
if let Some(at) = section.props.iter().position(|p| p.field == "block") {
|
||||
section.props.insert(
|
||||
at + 1,
|
||||
ro_prop(
|
||||
t!("Saved Path").as_ref(),
|
||||
"xref_saved_path",
|
||||
crate::modules::insert::xref_cmd::display_path(saved_path),
|
||||
),
|
||||
);
|
||||
}
|
||||
if section.props.iter().any(|p| p.field == "unit_factor") {
|
||||
section.props.push(ro_prop(
|
||||
t!("Layer property overrides").as_ref(),
|
||||
"xref_layer_overrides",
|
||||
if overrides { t!("Yes") } else { t!("No") }.into_owned(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_row(sections: &mut [crate::scene::model::object::PropSection], field: &str, value: String) {
|
||||
for section in sections.iter_mut() {
|
||||
if let Some(row) = section.props.iter_mut().find(|p| p.field == field) {
|
||||
@@ -4103,7 +4185,7 @@ fn dim_lineweight_label(dimlwd: i16) -> String {
|
||||
}
|
||||
|
||||
/// Human-readable INSUNITS name (DXF group 70 unit codes).
|
||||
fn insunits_name(code: i16) -> &'static str {
|
||||
pub(in crate::app) fn insunits_name(code: i16) -> &'static str {
|
||||
match code {
|
||||
1 => "Inches",
|
||||
2 => "Feet",
|
||||
@@ -4135,7 +4217,7 @@ fn insunits_name(code: i16) -> &'static str {
|
||||
}
|
||||
|
||||
/// Unit-conversion scale for a new INSERT.
|
||||
fn insert_unit_scale(host_units: i16, src_units: i16) -> Option<f64> {
|
||||
pub(in crate::app) fn insert_unit_scale(host_units: i16, src_units: i16) -> Option<f64> {
|
||||
let host_mm = insunits_to_mm(host_units)?;
|
||||
let src_mm = insunits_to_mm(src_units)?;
|
||||
let ratio = src_mm / host_mm;
|
||||
@@ -4145,7 +4227,7 @@ fn insert_unit_scale(host_units: i16, src_units: i16) -> Option<f64> {
|
||||
Some(ratio)
|
||||
}
|
||||
|
||||
fn format_unit_factor(factor: f64) -> String {
|
||||
pub(in crate::app) fn format_unit_factor(factor: f64) -> String {
|
||||
let magnitude = factor.abs();
|
||||
if magnitude > 0.0 && !(1.0e-4..1.0e7).contains(&magnitude) {
|
||||
format!("{factor:.4e}")
|
||||
|
||||
@@ -1163,6 +1163,8 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
self.reset_modal_geometry();
|
||||
if self.block_definition.is_some() {
|
||||
self.active_modal = Some(crate::app::ModalKind::BlockDefinition);
|
||||
} else if self.wblock.is_some() {
|
||||
self.active_modal = Some(crate::app::ModalKind::WriteBlock);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
@@ -3547,6 +3549,46 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task<Message> {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(axis) = match field {
|
||||
"xref_x_scale" => Some(0),
|
||||
"xref_y_scale" => Some(1),
|
||||
"xref_z_scale" => Some(2),
|
||||
_ => None,
|
||||
} {
|
||||
// An xref's Scale rows show the scale without the
|
||||
// unit conversion its INSERT carries; write it back
|
||||
// with that conversion.
|
||||
let value = val
|
||||
.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.filter(|v| v.is_finite() && *v != 0.0);
|
||||
if let Some(value) = value {
|
||||
let host = self.tabs[i].scene.document.header.insertion_units;
|
||||
for &handle in &handles {
|
||||
let factor = match self.tabs[i].scene.document.get_entity(handle) {
|
||||
Some(acadrust::EntityType::Insert(ins)) => self.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.block_records
|
||||
.get(&ins.block_name)
|
||||
.and_then(|br| {
|
||||
crate::app::properties::insert_unit_scale(host, br.units)
|
||||
})
|
||||
.unwrap_or(1.0),
|
||||
_ => continue,
|
||||
};
|
||||
if let Some(acadrust::EntityType::Insert(ins)) =
|
||||
self.tabs[i].scene.document.get_entity_mut(handle)
|
||||
{
|
||||
match axis {
|
||||
0 => ins.set_x_scale(value * factor),
|
||||
1 => ins.set_y_scale(value * factor),
|
||||
_ => ins.set_z_scale(value * factor),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if field == "block" {
|
||||
// Name row on a block reference: an existing name
|
||||
// re-points the selected inserts; a new one renames
|
||||
|
||||
+50
-4
@@ -1446,6 +1446,21 @@ impl OpenCADStudio {
|
||||
i: usize,
|
||||
path: std::path::PathBuf,
|
||||
set_current_path: bool,
|
||||
) -> Result<(), crate::io::SaveFailure> {
|
||||
let version = self.tabs[i].scene.document.version;
|
||||
self.save_tab_synchronously_protected_as(i, path, version, set_current_path)
|
||||
}
|
||||
|
||||
/// Synchronous protected save with an explicit output version. Automation
|
||||
/// uses this path so format conversion never depends on an implicit source
|
||||
/// version or silently upgrades to the newest DWG.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn save_tab_synchronously_protected_as(
|
||||
&mut self,
|
||||
i: usize,
|
||||
path: std::path::PathBuf,
|
||||
version: acadrust::DxfVersion,
|
||||
set_current_path: bool,
|
||||
) -> Result<(), crate::io::SaveFailure> {
|
||||
let previous_autosave = self.autosave_target(i);
|
||||
if self.tabs[i].recovery_save_as_required
|
||||
@@ -1499,7 +1514,6 @@ impl OpenCADStudio {
|
||||
Self::native_save_verification(&path, lease, expected_fingerprint)?;
|
||||
|
||||
self.prepare_native_save(i);
|
||||
let version = self.tabs[i].scene.document.version;
|
||||
let snapshot = self.tabs[i].scene.document_for_save();
|
||||
crate::io::save_owned_as_version_atomic(
|
||||
snapshot,
|
||||
@@ -1512,6 +1526,7 @@ impl OpenCADStudio {
|
||||
|
||||
if set_current_path {
|
||||
self.tabs[i].current_path = Some(path.clone());
|
||||
self.tabs[i].scene.document.version = version;
|
||||
}
|
||||
self.refresh_native_edit_guard_after_save(i, &path, path_changed, destination_lease);
|
||||
self.tabs[i].dirty = false;
|
||||
@@ -1911,12 +1926,22 @@ impl OpenCADStudio {
|
||||
// fetch them from the community repository before the user
|
||||
// studies garbled substitute text (unless recovery already
|
||||
// owns the modal slot).
|
||||
let missing = crate::io::font_repo::missing_shx_fonts(
|
||||
let mut missing = crate::io::font_repo::missing_shx_fonts(
|
||||
&self.tabs[i].scene.document,
|
||||
);
|
||||
if !missing.is_empty() && self.check_missing_fonts {
|
||||
missing.retain(|name| {
|
||||
!self
|
||||
.suppressed_missing_fonts
|
||||
.contains(&crate::io::font_repo::font_key(name))
|
||||
});
|
||||
// Read-only/MCP evaluation sessions must remain non-blocking:
|
||||
// report missing fonts through control state, but never offer
|
||||
// a network/download mutation from a read-only launch.
|
||||
if !missing.is_empty() && self.check_missing_fonts && !self.read_only {
|
||||
self.font_source_input = self.font_source_url.clone();
|
||||
self.missing_fonts = Some(missing);
|
||||
self.missing_fonts_path = self.tabs[i].current_path.clone();
|
||||
self.missing_fonts_downloading = false;
|
||||
self.active_modal = Some(crate::app::ModalKind::MissingFonts);
|
||||
}
|
||||
}
|
||||
@@ -2139,7 +2164,8 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
let mut recent_task = Task::none();
|
||||
let saved = match crate::io::save_to_bytes(&self.tabs[i].scene.document, &ext, version) {
|
||||
let document = self.tabs[i].scene.document_for_save();
|
||||
let saved = match crate::io::save_to_bytes(&document, &ext, version) {
|
||||
Ok(bytes) => {
|
||||
crate::sys::download_bytes(&filename, &bytes);
|
||||
let cache_name = std::path::Path::new(&filename)
|
||||
@@ -2337,6 +2363,15 @@ impl OpenCADStudio {
|
||||
});
|
||||
let clone_started = iced::time::Instant::now();
|
||||
let mut snapshot = self.tabs[i].scene.document_for_save();
|
||||
// First save: references attached with a relative path type while
|
||||
// the drawing had no file become relative to it.
|
||||
if self.tabs[i].current_path.is_none() && purpose != crate::app::SavePurpose::Autosave {
|
||||
crate::io::xref::make_relative(
|
||||
&mut snapshot,
|
||||
&self.tabs[i].xref_relative_on_save,
|
||||
&path,
|
||||
);
|
||||
}
|
||||
// Save-As across folders: rebase relative reference paths onto the
|
||||
// new base dir inside the snapshot only (live strings are untouched).
|
||||
if purpose == crate::app::SavePurpose::SaveAs {
|
||||
@@ -2639,6 +2674,14 @@ impl OpenCADStudio {
|
||||
}
|
||||
if outcome.set_current_path {
|
||||
let old_path = self.tabs[i].current_path.clone();
|
||||
if old_path.is_none() {
|
||||
let pending = std::mem::take(&mut self.tabs[i].xref_relative_on_save);
|
||||
crate::io::xref::make_relative(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&pending,
|
||||
&outcome.path,
|
||||
);
|
||||
}
|
||||
self.tabs[i].current_path = Some(outcome.path.clone());
|
||||
self.tabs[i].scene.document.version = outcome.version;
|
||||
if outcome.purpose == crate::app::SavePurpose::SaveAs {
|
||||
@@ -5617,6 +5660,9 @@ mod plot_paper_tests {
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let _ = app.update(Message::LayoutSwitch("Layout1".into()));
|
||||
let i = app.active_tab;
|
||||
// Millimetre drawing units, so a ratio plots as written; the metre
|
||||
// case has its own test.
|
||||
app.tabs[i].scene.document.header.insertion_units = 4;
|
||||
let mut ps = app.tabs[i]
|
||||
.scene
|
||||
.plot_settings_for("Layout1")
|
||||
|
||||
+378
-14
@@ -219,6 +219,8 @@ impl OpenCADStudio {
|
||||
Some(UnderlayLayers) => self.underlay_layers = None,
|
||||
Some(PdfImportSettings) => self.pdf_import_settings = None,
|
||||
Some(PdfImportFile) => self.pdf_import_file = None,
|
||||
Some(XrefAttach) => self.xref_attach = None,
|
||||
Some(WriteBlock) => self.wblock = None,
|
||||
Some(Hyperlink) => {
|
||||
self.hyperlink_editor_handles.clear();
|
||||
self.hyperlink_editor_url.clear();
|
||||
@@ -356,6 +358,14 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.active_modal == Some(super::ModalKind::WriteBlock) {
|
||||
if let Some(ref mut state) = self.wblock {
|
||||
if state.error_message.is_some() {
|
||||
state.error_message = None;
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
}
|
||||
return self.update(Message::CloseModal);
|
||||
}
|
||||
if self.active_modal == Some(super::ModalKind::BlockDefinition) {
|
||||
@@ -372,6 +382,13 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.active_modal == Some(super::ModalKind::WriteBlock) {
|
||||
if matches!(msg, Message::CommandFinalize)
|
||||
|| matches!(&msg, Message::ShortcutPressed(key) if key.rsplit('+').next() == Some("ENTER") || key.rsplit('+').next() == Some("RETURN"))
|
||||
{
|
||||
return self.update(Message::WblockApply);
|
||||
}
|
||||
}
|
||||
if is_modal_blocked_key_msg(&msg) {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -1191,13 +1208,20 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
Message::MissingFontsDownload => {
|
||||
if self.missing_fonts_downloading {
|
||||
return Task::none();
|
||||
}
|
||||
// Remember the source across sessions — and across drawings.
|
||||
let source = self.font_source_input.trim().to_string();
|
||||
if self.font_source_url != source {
|
||||
self.font_source_url = source.clone();
|
||||
self.save_config();
|
||||
}
|
||||
let fonts = self.missing_fonts.take().unwrap_or_default();
|
||||
let fonts = self.missing_fonts.clone().unwrap_or_default();
|
||||
if fonts.is_empty() {
|
||||
return Task::none();
|
||||
}
|
||||
self.missing_fonts_downloading = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
let source = crate::io::font_repo::FontSource::from_url(&source);
|
||||
@@ -1207,31 +1231,66 @@ impl OpenCADStudio {
|
||||
)
|
||||
}
|
||||
Message::MissingFontsDismiss => {
|
||||
self.missing_fonts = None;
|
||||
if self.missing_fonts_downloading {
|
||||
return Task::none();
|
||||
}
|
||||
for name in self.missing_fonts.take().unwrap_or_default() {
|
||||
self.suppressed_missing_fonts
|
||||
.insert(crate::io::font_repo::font_key(&name));
|
||||
}
|
||||
self.missing_fonts_path = None;
|
||||
self.close_active_modal();
|
||||
Task::none()
|
||||
}
|
||||
Message::MissingFontsResult(result) => {
|
||||
self.missing_fonts = None;
|
||||
self.close_active_modal();
|
||||
self.missing_fonts_downloading = false;
|
||||
match result {
|
||||
Ok(pairs) if pairs.is_empty() => {
|
||||
let unavailable = self.missing_fonts.take().unwrap_or_default();
|
||||
for name in &unavailable {
|
||||
self.suppressed_missing_fonts
|
||||
.insert(crate::io::font_repo::font_key(name));
|
||||
}
|
||||
self.missing_fonts_path = None;
|
||||
self.close_active_modal();
|
||||
self.command_line.push_error(crate::t!(
|
||||
"None of the missing fonts are in the community repository yet. Contribute them at github.com/huaninstratech/OpenCADStudio/tree/main/fonts."
|
||||
"None of the requested fonts are available from the selected source. Substitute fonts remain active; this notice will not repeat during this session."
|
||||
).as_ref());
|
||||
}
|
||||
Ok(pairs) => {
|
||||
let requested = self.missing_fonts.take().unwrap_or_default();
|
||||
let downloaded: rustc_hash::FxHashSet<String> = pairs
|
||||
.iter()
|
||||
.map(|(name, _)| crate::io::font_repo::font_key(name))
|
||||
.collect();
|
||||
let unavailable: Vec<String> = requested
|
||||
.into_iter()
|
||||
.filter(|name| {
|
||||
!downloaded.contains(&crate::io::font_repo::font_key(name))
|
||||
})
|
||||
.collect();
|
||||
for name in &unavailable {
|
||||
self.suppressed_missing_fonts
|
||||
.insert(crate::io::font_repo::font_key(name));
|
||||
}
|
||||
for (name, path) in &pairs {
|
||||
self.command_line.push_output(crate::tf!(
|
||||
"FONT Downloaded {name} → {path}",
|
||||
path = path.display()
|
||||
).as_ref());
|
||||
}
|
||||
if !unavailable.is_empty() {
|
||||
self.command_line.push_info(crate::tf!(
|
||||
"FONT Not available from the selected source: {fonts}. Substitute fonts remain active.",
|
||||
fonts = unavailable.join(", ")
|
||||
).as_ref());
|
||||
}
|
||||
// The downloaded files change glyph resolution for the
|
||||
// whole drawing — reload it through the standard open
|
||||
// pipeline so every wire is rebuilt with the real fonts.
|
||||
let i = self.active_tab;
|
||||
if let Some(path) = self.tabs[i].current_path.clone() {
|
||||
let path = self.missing_fonts_path.take();
|
||||
self.close_active_modal();
|
||||
if let Some(path) = path {
|
||||
return Task::done(Message::OpenExternal(path));
|
||||
}
|
||||
self.command_line.push_info(crate::t!(
|
||||
@@ -1239,6 +1298,8 @@ impl OpenCADStudio {
|
||||
).as_ref());
|
||||
}
|
||||
Err(e) => {
|
||||
// Keep the prompt open: the user can correct a custom
|
||||
// source or retry a transient network failure.
|
||||
self.command_line.push_error(crate::tf!("Font download failed: {e}").as_ref());
|
||||
}
|
||||
}
|
||||
@@ -1332,15 +1393,13 @@ impl OpenCADStudio {
|
||||
),
|
||||
|
||||
Message::XAttachPickResult(Ok(path)) => {
|
||||
use crate::command::CadCommand;
|
||||
use crate::modules::insert::xattach::XAttachCommand;
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let cmd = XAttachCommand::with_path(path_str);
|
||||
let i = self.active_tab;
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(cmd));
|
||||
self.open_xref_attach_dialog(path);
|
||||
Task::none()
|
||||
}
|
||||
message @ (Message::AttachPick
|
||||
| Message::AttachPickResult(_)
|
||||
| Message::XrefAttach(_)
|
||||
| Message::XrefAttachBrowseResult(_)) => self.update_xref_attach(message),
|
||||
|
||||
Message::XAttachPickResult(Err(e)) => {
|
||||
if e != "Cancelled" {
|
||||
@@ -4935,6 +4994,294 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.commit_block_definition(false)
|
||||
}
|
||||
Message::WblockSourceMode(mode) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.source_mode = mode;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockBlockName(name) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.block_name = name;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockBlockSelect(name) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.block_name = name.clone();
|
||||
let trimmed = name.trim();
|
||||
if !trimmed.is_empty() {
|
||||
let current_path = std::path::Path::new(&state.file_path);
|
||||
let file_stem = current_path.file_stem().and_then(|s| s.to_str());
|
||||
let is_default_or_block = file_stem == Some("new_block")
|
||||
|| state.existing_blocks.iter().any(|b| Some(b.as_str()) == file_stem);
|
||||
if is_default_or_block {
|
||||
let new_file_name = format!("{}.dwg", trimmed);
|
||||
if let Some(parent) = current_path.parent() {
|
||||
if !parent.as_os_str().is_empty() {
|
||||
state.file_path = parent.join(new_file_name).to_string_lossy().to_string();
|
||||
} else {
|
||||
state.file_path = new_file_name;
|
||||
}
|
||||
} else {
|
||||
state.file_path = new_file_name;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockPickPoint => {
|
||||
self.active_modal = None;
|
||||
let cmd = crate::modules::insert::wblock::WblockPickBasePointCommand;
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd));
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockBaseX(val) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.base_point_x = val;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockBaseY(val) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.base_point_y = val;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockBaseZ(val) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.base_point_z = val;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockSelectObjects => {
|
||||
self.active_modal = None;
|
||||
use crate::modules::draw::select::SelectObjectsCommand;
|
||||
let cmd = SelectObjectsCommand::plain("WBLOCK", "WBLOCK_OBJECTS_GATHERED");
|
||||
self.command_line.push_info(&cmd.prompt());
|
||||
self.tabs[self.active_tab].active_cmd = Some(Box::new(cmd));
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockQuickSelect => {
|
||||
self.active_modal = None;
|
||||
self.on_qselect_open()
|
||||
}
|
||||
Message::WblockObjectMode(mode) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.object_mode = mode;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockFilePath(path) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.file_path = path;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockBrowsePath => {
|
||||
let default_name = if let Some(state) = self.wblock.as_ref() {
|
||||
let p = std::path::Path::new(&state.file_path);
|
||||
p.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.unwrap_or("new_block.dwg")
|
||||
.to_string()
|
||||
} else {
|
||||
"new_block.dwg".to_string()
|
||||
};
|
||||
Task::perform(
|
||||
async move {
|
||||
let path = crate::sys::file_dialog()
|
||||
.set_title(crate::t!("Save Block As").as_ref())
|
||||
.set_file_name(&default_name)
|
||||
.add_filter(crate::t!("DWG Files").as_ref(), &["dwg"])
|
||||
.add_filter(crate::t!("DXF Files").as_ref(), &["dxf"])
|
||||
.save_file()
|
||||
.await
|
||||
.map(|h| crate::sys::handle_path(&h));
|
||||
path
|
||||
},
|
||||
|path| Message::WblockBrowsePathResult(path),
|
||||
)
|
||||
}
|
||||
Message::WblockBrowsePathResult(opt_path) => {
|
||||
if let Some(path) = opt_path {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.file_path = path.to_string_lossy().to_string();
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockUnit(unit) => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.unit = unit;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockDismissError => {
|
||||
if let Some(state) = self.wblock.as_mut() {
|
||||
state.error_message = None;
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockHelp => {
|
||||
self.command_line.push_info(
|
||||
crate::t!("WBLOCK writes objects, a block, or the entire drawing to a new drawing file.").as_ref(),
|
||||
);
|
||||
Task::none()
|
||||
}
|
||||
Message::WblockApply => {
|
||||
let Some(state) = self.wblock.as_mut() else {
|
||||
return Task::none();
|
||||
};
|
||||
use crate::ui::window::wblock::{WblockObjectMode, WblockSourceMode};
|
||||
match state.source_mode {
|
||||
WblockSourceMode::Block => {
|
||||
let name = state.block_name.trim();
|
||||
if name.is_empty() {
|
||||
state.error_message =
|
||||
Some(crate::t!("Please select or enter a block name.").into_owned());
|
||||
return Task::none();
|
||||
}
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].scene.document.block_records.get(name).is_none() {
|
||||
state.error_message = Some(
|
||||
crate::tf!("Block \"{}\" does not exist in drawing.", name)
|
||||
.into_owned(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
WblockSourceMode::Objects => {
|
||||
if state.selected_handles.is_empty() {
|
||||
state.error_message = Some(
|
||||
crate::t!("No objects selected. You must select objects to define a block.")
|
||||
.into_owned(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
WblockSourceMode::EntireDrawing => {}
|
||||
}
|
||||
|
||||
let path_str = state.file_path.trim().to_string();
|
||||
if path_str.is_empty() {
|
||||
state.error_message =
|
||||
Some(crate::t!("Please specify a file name and path.").into_owned());
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
let mut path = std::path::PathBuf::from(path_str);
|
||||
if path.extension().is_none() {
|
||||
path.set_extension("dwg");
|
||||
}
|
||||
|
||||
let i = self.active_tab;
|
||||
if let Some(current_path) = self.tabs[i].current_path.as_ref() {
|
||||
if current_path == &path {
|
||||
state.error_message = Some(
|
||||
crate::t!("Cannot write to the current drawing file.").into_owned(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
|
||||
let state = self.wblock.take().unwrap();
|
||||
self.active_modal = None;
|
||||
let document = self.tabs[i].scene.document_for_save();
|
||||
let source_mode = state.source_mode;
|
||||
let block_name = state.block_name.trim().to_string();
|
||||
let handles = state.selected_handles.clone();
|
||||
let base_point = state.parse_base_point();
|
||||
let unit = state.unit;
|
||||
let object_mode = state.object_mode;
|
||||
|
||||
if source_mode == WblockSourceMode::Objects {
|
||||
match object_mode {
|
||||
WblockObjectMode::Retain => {}
|
||||
WblockObjectMode::Delete => {
|
||||
self.push_undo_snapshot(i, "WBLOCK");
|
||||
self.tabs[i].scene.erase_entities(&handles);
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.refresh_properties();
|
||||
}
|
||||
WblockObjectMode::Convert => {
|
||||
let block_name_for_conv = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("WBLOCK")
|
||||
.to_string();
|
||||
self.push_undo_snapshot(i, "WBLOCK");
|
||||
let ucs = self.tabs[i].ucs_xform();
|
||||
let world_to_block = ucs.to_ucs_transform_at(base_point);
|
||||
let block_to_world = ucs.to_wcs_transform_at(base_point);
|
||||
let options = crate::scene::CreateBlockOptions {
|
||||
name: block_name_for_conv,
|
||||
handles: handles.clone(),
|
||||
base_point,
|
||||
world_to_block,
|
||||
block_to_world,
|
||||
mode: crate::ui::window::block_definition::BlockObjectMode::Convert,
|
||||
annotative: false,
|
||||
match_orientation: false,
|
||||
scale_uniformly: false,
|
||||
allow_exploding: true,
|
||||
unit,
|
||||
description: String::new(),
|
||||
hyperlink_url: String::new(),
|
||||
hyperlink_desc: String::new(),
|
||||
redefine: true,
|
||||
};
|
||||
let _ = self.tabs[i].scene.create_block_with_options(options);
|
||||
self.tabs[i].dirty = true;
|
||||
self.tabs[i].scene.bump_geometry();
|
||||
self.refresh_properties();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let worker_path = path.clone();
|
||||
let display_name = match source_mode {
|
||||
WblockSourceMode::Block => block_name.clone(),
|
||||
WblockSourceMode::EntireDrawing => "*".to_string(),
|
||||
WblockSourceMode::Objects => "*".to_string(),
|
||||
};
|
||||
let worker_display = display_name.clone();
|
||||
|
||||
file::background_task(
|
||||
move || {
|
||||
let out_doc = match source_mode {
|
||||
WblockSourceMode::Block => {
|
||||
let mut d = crate::modules::insert::wblock::extract_block_to_doc(
|
||||
&document,
|
||||
&block_name,
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
d.header.insertion_units = unit;
|
||||
d
|
||||
}
|
||||
WblockSourceMode::EntireDrawing => {
|
||||
let mut d = document.clone();
|
||||
d.header.insertion_units = unit;
|
||||
d
|
||||
}
|
||||
WblockSourceMode::Objects => {
|
||||
crate::modules::insert::wblock::extract_entities_to_doc_with_base(
|
||||
&document,
|
||||
&handles,
|
||||
base_point,
|
||||
unit,
|
||||
)
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
crate::io::save(&out_doc, &worker_path).map_err(|e| e.to_string())
|
||||
},
|
||||
move |result| Message::WblockWriteFinished(worker_display, path, result),
|
||||
)
|
||||
}
|
||||
Message::ToleranceDialogField(field) => {
|
||||
if let Some(state) = self.geometric_tolerance.as_mut() {
|
||||
state.apply_field(field);
|
||||
@@ -5889,6 +6236,8 @@ impl OpenCADStudio {
|
||||
self.reset_modal_geometry();
|
||||
if self.block_definition.is_some() {
|
||||
self.active_modal = Some(super::ModalKind::BlockDefinition);
|
||||
} else if self.wblock.is_some() {
|
||||
self.active_modal = Some(super::ModalKind::WriteBlock);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -6095,6 +6444,15 @@ impl OpenCADStudio {
|
||||
.collect();
|
||||
block_def.error_message = None;
|
||||
self.active_modal = Some(super::ModalKind::BlockDefinition);
|
||||
} else if let Some(ref mut wblock) = self.wblock {
|
||||
wblock.selected_handles = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
.into_iter()
|
||||
.map(|(h, _)| h)
|
||||
.collect();
|
||||
wblock.error_message = None;
|
||||
self.active_modal = Some(super::ModalKind::WriteBlock);
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
@@ -8071,6 +8429,12 @@ impl OpenCADStudio {
|
||||
if self.active_modal == Some(super::ModalKind::RecoveryPrompt) {
|
||||
return self.update(Message::RecoveryDecline);
|
||||
}
|
||||
// The title-bar X, Escape, and MCP `close_modal` action mean
|
||||
// the same thing as Skip for this prompt. Record that choice
|
||||
// so reopening the drawing does not immediately nag again.
|
||||
if self.active_modal == Some(super::ModalKind::MissingFonts) {
|
||||
return self.update(Message::MissingFontsDismiss);
|
||||
}
|
||||
// The Options window's × and Esc behave like its Close button.
|
||||
if self.active_modal == Some(super::ModalKind::Options) {
|
||||
return self.update(Message::OptionsClose);
|
||||
|
||||
@@ -260,8 +260,7 @@ mod tests {
|
||||
app.tabs[i].scene.page_setup_save("Site", ps.clone());
|
||||
ps.paper_size = "ISO_A2_(420.00_x_594.00_MM)".into();
|
||||
app.tabs[i].scene.page_setup_save("Overview", ps);
|
||||
let p = source.to_string_lossy().replace('\\', "/");
|
||||
let save = format!(r#"{{"op":"save","path":"{p}"}}"#);
|
||||
let save = serde_json::json!({"op":"save","path":source}).to_string();
|
||||
let reply = app.automation_op(&save);
|
||||
assert_eq!(reply["ok"], true, "{reply}");
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ impl OpenCADStudio {
|
||||
Some(K::UnderlayLayers) => crate::t!("Underlay Layers").into_owned(),
|
||||
Some(K::PdfImportSettings) => crate::t!("PDF Import Settings").into_owned(),
|
||||
Some(K::PdfImportFile) => crate::t!("Import PDF").into_owned(),
|
||||
Some(K::XrefAttach) => crate::t!("Attach External Reference").into_owned(),
|
||||
Some(K::WriteBlock) => crate::t!("Write Block").into_owned(),
|
||||
Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(),
|
||||
Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(),
|
||||
Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(),
|
||||
@@ -535,6 +537,19 @@ impl OpenCADStudio {
|
||||
crate::ui::window::pdf_dialogs::view_import_file(state, flow)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::XrefAttach => {
|
||||
let state = self.xref_attach.as_ref()?;
|
||||
let height = if state.details { 520 } else { 450 };
|
||||
sized_flow(ex, 740, height, |flow| {
|
||||
crate::ui::window::xref_attach::view_window(state, flow)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::WriteBlock => {
|
||||
let state = self.wblock.as_ref()?;
|
||||
sized_flow(ex, 440, 395, |flow| {
|
||||
crate::ui::window::wblock::view_window(state, flow)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::GeometricTolerance => {
|
||||
let state = self.geometric_tolerance.as_ref()?;
|
||||
sized_flow(ex, 670, 530, |flow| {
|
||||
@@ -1704,6 +1719,7 @@ impl OpenCADStudio {
|
||||
crate::ui::window::missing_fonts::view_window(
|
||||
fonts,
|
||||
&font_source,
|
||||
self.missing_fonts_downloading,
|
||||
flow,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user