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:
ramox81 committed 2026-09-24 19:38:48 +03:00
commit c1ecb0edaf
77 files changed
+5695 -606

No files matched your search

+8 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+479
View File
@@ -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
}
}
+9
View File
@@ -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
View File
@@ -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
+4
View File
@@ -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,
+46
View File
@@ -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
View File
@@ -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
View File
@@ -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}")
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+1 -2
View File
@@ -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}");
}
+16
View File
@@ -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,
)
})