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,
)
})
+5
View File
@@ -95,6 +95,11 @@ pub struct Cli {
#[arg(help = crate::t!("Convert IN to OUT using the output file extension, then exit.").into_owned(), long_help = None)]
pub export: Option<Vec<PathBuf>>,
/// Explicit output CAD version for --export (R14, 2000, 2004, 2007,
/// 2010, 2013 or 2018). Without it the source document version is kept.
#[arg(long, value_name = "VERSION", requires = "export")]
pub target_version: Option<String>,
/// Run a command script at startup: one command line per line of FILE.
#[arg(long, value_name = "FILE")]
#[arg(help = crate::t!("Run a command script at startup, one command per line.").into_owned(), long_help = None)]
+3 -3
View File
@@ -2851,9 +2851,9 @@ pub trait CadCommand: Send {
false
}
/// If this command is XATTACH, returns the file path to attach.
/// Default: None.
fn xattach_path(&self) -> Option<String> {
/// If this command is XATTACH, what it attaches and how the reference is
/// stored. Default: None.
fn xattach_request(&self) -> Option<crate::modules::insert::xattach::XrefAttachRequest> {
None
}
+7
View File
@@ -10,6 +10,13 @@ use std::path::PathBuf;
/// file name onto it and `create_dir_all` its parent before writing.
#[cfg(not(target_arch = "wasm32"))]
pub fn config_dir() -> Option<PathBuf> {
// Tests must never read or write the person's real settings: a test run
// once raced dozens of tests through an alias-table migration and left the
// user's alias file holding a single line. Every store routes through here,
// so one per-process scratch folder isolates them all.
if cfg!(test) {
return Some(std::env::temp_dir().join(format!("ocs-test-config-{}", std::process::id())));
}
let base: PathBuf = if cfg!(target_os = "windows") {
std::env::var_os("APPDATA").map(PathBuf::from)?
} else if cfg!(target_os = "macos") {
+15
View File
@@ -42,6 +42,15 @@ pub enum FontSource {
Custom(String),
}
/// Stable key for comparing bare font names without path or case differences.
pub fn font_key(name: &str) -> String {
name.rsplit(['/', '\\'])
.next()
.unwrap_or(name)
.trim()
.to_ascii_lowercase()
}
impl FontSource {
/// Empty / whitespace input selects the community repository.
pub fn from_url(url: &str) -> Self {
@@ -265,6 +274,12 @@ mod tests {
assert_eq!(percent_encode_path("vn 30f.shx"), "vn%2030f.shx");
}
#[test]
fn font_keys_are_bare_trimmed_and_case_insensitive() {
assert_eq!(font_key(r#" C:\Fonts\ROMANS.SHX "#), "romans.shx");
assert_eq!(font_key("folder/simplex.shx"), "simplex.shx");
}
#[test]
fn rejects_a_non_listing_response() {
assert!(parse_contents("{\"message\":\"Not Found\"}").is_err());
+19
View File
@@ -1361,6 +1361,25 @@ pub fn parse_save_format(format: &str) -> (&'static str, acadrust::DxfVersion) {
(ext, version)
}
/// Parse an explicit automation/CLI target version without silently falling
/// back to a newer format. Accepts either release names (`R14`, `2000`, …)
/// or their DXF codes (`AC1014`, `AC1015`, …).
pub fn parse_target_version(value: &str) -> Result<acadrust::DxfVersion, String> {
use acadrust::DxfVersion;
match value.trim().to_ascii_uppercase().as_str() {
"R14" | "14" | "AC1014" => Ok(DxfVersion::AC1014),
"2000" | "AC1015" => Ok(DxfVersion::AC1015),
"2004" | "AC1018" => Ok(DxfVersion::AC1018),
"2007" | "AC1021" => Ok(DxfVersion::AC1021),
"2010" | "AC1024" => Ok(DxfVersion::AC1024),
"2013" | "AC1027" => Ok(DxfVersion::AC1027),
"2018" | "AC1032" => Ok(DxfVersion::AC1032),
_ => Err(format!(
"unsupported target version {value:?}; use R14, 2000, 2004, 2007, 2010, 2013 or 2018"
)),
}
}
/// Reverse of [`parse_save_format`]: the Save-dialog format string for a
/// version + DXF/DWG choice (e.g. `AC1018, is_dxf=false` -> `"DWG 2004"`).
/// Used to default the Save-As dropdown to the loaded file's version so a
+25 -1
View File
@@ -1073,7 +1073,18 @@ fn gdi_raster_print(
}
/// Open a file with the OS default application (used for print preview).
#[cfg(not(target_arch = "wasm32"))]
#[cfg(all(not(target_arch = "wasm32"), test))]
pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
// Unit tests exercise the complete preview pipeline, but must not launch
// the user's PDF application or leave generated previews in the temp
// directory. Production viewers need the file after this call returns;
// the test path has no external consumer, so it can be removed now.
std::fs::remove_file(path)
.map_err(|error| format!("Could not consume test preview: {error}"))
}
/// Open a file with the OS default application (used for print preview).
#[cfg(all(not(target_arch = "wasm32"), not(test)))]
pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
let p = path.to_string_lossy().to_string();
#[cfg(target_os = "windows")]
@@ -1099,6 +1110,19 @@ pub fn open_in_viewer(path: &std::path::Path) -> Result<(), String> {
.map_err(|e| format!("Could not open preview: {e}"))
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod preview_isolation_tests {
#[test]
fn a_test_preview_is_consumed_without_leaving_a_temp_file() {
let path = super::temp_pdf_path("preview_test");
std::fs::write(&path, b"test preview").unwrap();
super::open_in_viewer(&path).unwrap();
assert!(!path.exists(), "test preview was not cleaned up: {}", path.display());
}
}
/// Ask the registered PDF application to print `path` on `printer` through
/// its `printto` verb, once per copy. The printer name is quoted: some
/// `printto` command templates do not quote their `%2`.
+260 -26
View File
@@ -324,6 +324,82 @@ pub fn register_underlay_sources(doc: &CadDocument, base_dir: &Path) {
}
}
/// Layer properties an override can change, as compared for the
/// "Layer property overrides" row.
#[derive(Clone, PartialEq)]
struct LayerLook {
color: acadrust::types::Color,
line_type: String,
line_weight: acadrust::types::LineWeight,
off: bool,
frozen: bool,
locked: bool,
plottable: bool,
}
impl LayerLook {
fn of(layer: &acadrust::tables::Layer) -> Self {
Self {
color: layer.color.clone(),
line_type: layer.line_type.to_uppercase(),
line_weight: layer.line_weight,
off: layer.flags.off,
frozen: layer.flags.frozen,
locked: layer.flags.locked,
plottable: layer.is_plottable,
}
}
}
/// The source file's layers by upper-case name, read once per file version.
fn source_layers(path: &Path) -> Option<HashMap<String, LayerLook>> {
type Cache = std::sync::Mutex<HashMap<PathBuf, (std::time::SystemTime, HashMap<String, LayerLook>)>>;
static CACHE: std::sync::OnceLock<Cache> = std::sync::OnceLock::new();
let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
let cache = CACHE.get_or_init(Default::default);
if let Some((stamp, layers)) = cache.lock().ok()?.get(path) {
if *stamp == modified {
return Some(layers.clone());
}
}
let doc = super::load_file(path).ok()?;
let layers: HashMap<String, LayerLook> = doc
.layers
.iter()
.map(|layer| (layer.name.to_uppercase(), LayerLook::of(layer)))
.collect();
cache
.lock()
.ok()?
.insert(path.to_path_buf(), (modified, layers.clone()));
Some(layers)
}
/// Whether any of reference `name`'s layers was changed in the host — the
/// Properties "Layer property overrides" row. Compares the host's dependent
/// layers with the source file's own.
pub fn layer_overrides(doc: &CadDocument, name: &str, raw_path: &str, base_dir: &Path) -> bool {
let Some(source) = resolve_path(raw_path, base_dir).and_then(|p| source_layers(&p)) else {
return false;
};
let prefix = format!("{}|", name.to_uppercase());
doc.layers.iter().any(|layer| {
let upper = layer.name.to_uppercase();
let Some(own) = upper.strip_prefix(&prefix) else {
return false;
};
let Some(original) = source.get(own) else {
return false;
};
let mut here = LayerLook::of(layer);
// Dependent linetypes carry the reference prefix.
if let Some(stripped) = here.line_type.strip_prefix(&prefix) {
here.line_type = stripped.to_string();
}
here != *original
})
}
/// Try to build an absolute path from a raw xref path string.
/// Handles absolute paths, relative paths, and Windows-style separators.
fn resolve_path(raw: &str, base_dir: &Path) -> Option<PathBuf> {
@@ -361,18 +437,44 @@ fn resolve_path(raw: &str, base_dir: &Path) -> Option<PathBuf> {
None
}
/// Make sure `doc` has BLOCK + ENDBLK entities for `block_name`.
/// These are required so renderers can find the block content.
fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) {
let has_block = doc
.entities()
.any(|e| matches!(e, EntityType::Block(b) if b.name == block_name));
if has_block {
/// Give a block record its BLOCK / ENDBLK markers, linked through the
/// record's marker handles. The DWG writer emits the markers the record
/// points at; a record left pointing at a null handle is written with
/// handle 0, which makes the whole drawing unreadable elsewhere.
pub(crate) fn ensure_block_entities(doc: &mut CadDocument, block_name: &str) {
let Some((owner, begin, end, path)) = doc.block_records.get(block_name).map(|br| {
(
br.handle,
br.block_entity_handle,
br.block_end_handle,
br.xref_path.clone(),
)
}) else {
return;
};
if begin.is_null() || !matches!(doc.get_entity(begin), Some(EntityType::Block(_))) {
let handle = doc.allocate_handle();
let mut block = Block::new(block_name, Vector3::zero());
if !path.is_empty() {
block = block.with_xref_path(&path);
}
block.common.handle = handle;
block.common.owner_handle = owner;
let _ = doc.add_entity(EntityType::Block(block));
if let Some(br) = doc.block_records.get_mut(block_name) {
br.block_entity_handle = handle;
}
}
if end.is_null() || !matches!(doc.get_entity(end), Some(EntityType::BlockEnd(_))) {
let handle = doc.allocate_handle();
let mut block_end = BlockEnd::new();
block_end.common.handle = handle;
block_end.common.owner_handle = owner;
let _ = doc.add_entity(EntityType::BlockEnd(block_end));
if let Some(br) = doc.block_records.get_mut(block_name) {
br.block_end_handle = handle;
}
}
let b = Block::new(block_name, Vector3::zero());
let _ = doc.add_entity(EntityType::Block(b));
let _ = doc.add_entity(EntityType::BlockEnd(BlockEnd::new()));
}
/// Shared symbol-remap core for XREF load-merge and BIND (SPIKE3 REFACTOR
@@ -490,6 +592,7 @@ struct XrefSymbolMaps {
layers: HashMap<String, String>,
linetypes: HashMap<String, String>,
text_styles: HashMap<String, String>,
text_style_handles: HashMap<Handle, Handle>,
dim_styles: HashMap<String, String>,
blocks: HashMap<String, String>,
br_handles: HashMap<Handle, Handle>,
@@ -512,16 +615,55 @@ fn import_xref_symbols(
) -> XrefSymbolMaps {
let mut maps = XrefSymbolMaps::default();
// A layer's plot style and material point at objects of the file it came
// from; carried over verbatim they point at whatever the host keeps under
// those handles. Take the host's defaults (its layer 0) instead.
// ponytail: every imported layer gets the default plot style and material;
// map them by name if a reference ever carries non-default ones.
let (host_plotstyle, host_material) = doc
.layers
.get("0")
.map(|layer| (layer.plotstyle_handle, layer.material))
.unwrap_or((Handle::NULL, Handle::NULL));
for layer in xref_doc.layers.iter() {
let old = layer.name.clone();
let new = naming.name_for(SymbolKind::Layer, &old);
let mut cloned = layer.clone();
cloned.name = new.clone();
cloned.plotstyle_handle = host_plotstyle;
cloned.material = host_material;
cloned.set_handle(doc.allocate_handle());
doc.layers.add_or_replace(cloned);
maps.layers.insert(old.to_uppercase(), new);
}
for ts in xref_doc.text_styles.iter() {
// A shape-file style has no name of its own; the host's style for the
// same shape file serves the imported linetypes.
if ts.is_shape_file {
let same_file = doc
.text_styles
.iter()
.find(|host| host.is_shape_file && host.font_file.eq_ignore_ascii_case(&ts.font_file))
.map(|host| host.handle);
if let Some(host_handle) = same_file {
maps.text_style_handles.insert(ts.handle, host_handle);
continue;
}
}
let old = ts.name.clone();
let new = naming.name_for(SymbolKind::TextStyle, &old);
let mut cloned = ts.clone();
cloned.name = new.clone();
let new_handle = doc.allocate_handle();
cloned.set_handle(new_handle);
cloned.xref_dependent = false;
doc.text_styles.add_or_replace(cloned);
maps.text_style_handles.insert(ts.handle, new_handle);
maps.text_styles.insert(old.to_uppercase(), new);
}
for lt in xref_doc.line_types.iter() {
let old = lt.name.clone();
if is_sentinel_linetype(&old) {
@@ -530,22 +672,20 @@ fn import_xref_symbols(
let new = naming.name_for(SymbolKind::Linetype, &old);
let mut cloned = lt.clone();
cloned.name = new.clone();
// Shape and text segments name their style by handle: point them at
// the imported copy, not at the source file's record.
for element in &mut cloned.elements {
if let Some(complex) = element.complex.as_mut() {
if let Some(new_style) = maps.text_style_handles.get(&complex.style_handle) {
complex.style_handle = *new_style;
}
}
}
cloned.set_handle(doc.allocate_handle());
doc.line_types.add_or_replace(cloned);
maps.linetypes.insert(old.to_uppercase(), new);
}
for ts in xref_doc.text_styles.iter() {
let old = ts.name.clone();
let new = naming.name_for(SymbolKind::TextStyle, &old);
let mut cloned = ts.clone();
cloned.name = new.clone();
cloned.set_handle(doc.allocate_handle());
cloned.xref_dependent = false;
doc.text_styles.add_or_replace(cloned);
maps.text_styles.insert(old.to_uppercase(), new);
}
for ds in xref_doc.dim_styles.iter() {
let old = ds.name.clone();
let new = naming.name_for(SymbolKind::DimStyle, &old);
@@ -817,6 +957,21 @@ fn merge_xref_into_block(
prefix: prefix.to_string(),
};
let maps = import_xref_symbols(doc, &xref_doc, &mut naming);
// Dependent layers and linetypes carry the xref flag and their owning
// reference, the way the file stores them; without it the host writes
// plain records with a "|" in the name, which other readers reject.
for name in maps.layers.values() {
if let Some(layer) = doc.layers.get_mut(name) {
layer.flags.xref_dependent = true;
layer.xref_block_record_handle = br_handle;
}
}
for name in maps.linetypes.values() {
if let Some(linetype) = doc.line_types.get_mut(name) {
linetype.xref_dependent = true;
linetype.xref_block_record_handle = br_handle;
}
}
// ── Entities (shared helper) ────────────────────────────────────────
// The unremapped-handle count is ignored on the load path (BIND reports
@@ -1730,6 +1885,64 @@ fn same_file(a: &Path, b: &Path) -> bool {
}
}
/// Make the named references' paths relative to `host` (their drawing's
/// first save); a reference that cannot be relative keeps its full path.
pub fn make_relative(
doc: &mut CadDocument,
names: &rustc_hash::FxHashSet<String>,
host: &Path,
) {
for name in names {
let Some(key) = doc.block_records.get(name).map(|br| br.handle.value()) else {
continue;
};
let _ = apply_pathtype(doc, key, crate::io::xref_model::Pathtype::Relative, host);
}
}
/// Drop from a save copy what resolving each xref merged into the host but
/// the file never stores: the reference's own geometry, its nested block
/// definitions and its text and dimension styles. Dependent layers and
/// linetypes stay (flagged), so layer overrides kept under VISRETAIN persist.
pub fn strip_resolved_xref_content(doc: &mut CadDocument) {
let xrefs: Vec<(String, Handle)> = doc
.block_records
.iter()
.filter(|br| br.flags.is_xref || br.flags.is_xref_overlay)
.map(|br| (br.name.clone(), br.handle))
.collect();
for (name, handle) in xrefs {
let (begin, end) = doc
.block_records
.get(&name)
.map(|br| (br.block_entity_handle, br.block_end_handle))
.unwrap_or((Handle::NULL, Handle::NULL));
let owned: Vec<Handle> = doc
.entities()
.filter(|e| e.common().owner_handle == handle)
.map(|e| e.common().handle)
.filter(|h| *h != begin && *h != end)
.collect();
for h in owned {
doc.remove_entity(h);
}
if let Some(br) = doc.block_records.get_mut(&name) {
br.entity_handles.clear();
}
remove_dependent_symbols(doc, &name, false);
}
}
/// Text styles a kept linetype draws its shapes or text with.
fn linetype_styles(doc: &CadDocument) -> HashSet<Handle> {
doc.line_types
.iter()
.flat_map(|linetype| linetype.elements.iter())
.filter_map(|element| element.complex.as_ref().map(|complex| complex.style_handle))
.filter(|handle| !handle.is_null())
.collect()
}
/// Remove one xref's `name|*` dependent symbols (case-insensitive): layers,
/// linetypes, text styles, dim styles, and nested block records — plus every
/// entity and draw-order table owned by those dependent blocks.
@@ -1738,6 +1951,10 @@ fn same_file(a: &Path, b: &Path) -> bool {
/// (which drops the previous merge before re-merging under `$N$` names). The
/// xref BlockRecord itself, its INSERTs, and non-prefixed symbols stay.
fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) {
remove_dependent_symbols(doc, name, true);
}
fn remove_dependent_symbols(doc: &mut CadDocument, name: &str, layers_too: bool) {
let prefix = format!("{}|", name.to_uppercase());
let is_dep = |n: &str| n.to_uppercase().starts_with(&prefix);
// Dependent blocks first — their handles route the owned-entity cleanup.
@@ -1774,7 +1991,7 @@ fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) {
let layers: Vec<String> = doc
.layers
.names()
.filter(|n| is_dep(n))
.filter(|n| layers_too && is_dep(n))
.map(|s| s.to_string())
.collect();
for n in layers {
@@ -1783,21 +2000,38 @@ fn remove_pipe_symbols(doc: &mut CadDocument, name: &str) {
let lts: Vec<String> = doc
.line_types
.names()
.filter(|n| is_dep(n))
.filter(|n| layers_too && is_dep(n))
.map(|s| s.to_string())
.collect();
for n in lts {
doc.line_types.remove(&n);
}
// Styles a kept dependent linetype still draws with stay, flagged.
let used = if layers_too {
HashSet::default()
} else {
linetype_styles(doc)
};
let styles: Vec<String> = doc
.text_styles
.names()
.filter(|n| is_dep(n))
.map(|s| s.to_string())
.iter()
.filter(|style| is_dep(&style.name) && !used.contains(&style.handle))
.map(|style| style.name.clone())
.collect();
for n in styles {
doc.text_styles.remove(&n);
}
let owner = doc
.block_records
.get(name)
.map(|br| br.handle)
.unwrap_or(Handle::NULL);
for style in doc.text_styles.iter_mut() {
if is_dep(&style.name) {
style.xref_dependent = true;
style.xref_block_record_handle = owner;
}
}
let dims: Vec<String> = doc
.dim_styles
.names()
+6 -1
View File
@@ -247,6 +247,11 @@ pub fn to_pathtype_result(
if parts.is_empty() {
return Ok(file_name_only(path));
}
// A path at or below the host folder starts "./" ("./plan.dwg",
// "./refs/plan.dwg"), as the reference writes it; one above starts "../".
if parts[0] != ".." {
parts.insert(0, ".".to_string());
}
Ok(parts.join("/"))
}
}
@@ -846,7 +851,7 @@ mod tests {
let full = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Full);
assert_eq!(full, "C:/Drawings/refs/plan.dwg");
let rel = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::Relative);
assert_eq!(rel, "refs/plan.dwg");
assert_eq!(rel, "./refs/plan.dwg");
let none = to_pathtype("C:/Drawings/refs/plan.dwg", host, Pathtype::None);
assert_eq!(none, "plan.dwg");
}
+32 -7
View File
@@ -3,6 +3,17 @@
pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static str)> {
match source {
"Entire drawing" => Some(("common", "entire-drawing")),
"Delete from drawing" => Some(("common", "delete-from-drawing")),
"Destination" => Some(("common", "destination")),
"File name and path:" => Some(("common", "file-name-and-path")),
"Insert units:" => Some(("common", "insert-units-label")),
"Please select or enter a block name." => Some(("common", "wblock-err-no-block-name")),
"Block \"{}\" does not exist in drawing." => Some(("common", "wblock-err-block-not-found")),
"Please specify a file name and path." => Some(("common", "wblock-err-no-path")),
"Cannot write to the current drawing file." => Some(("common", "wblock-err-current-drawing")),
"WBLOCK writes objects, a block, or the entire drawing to a new drawing file." => Some(("common", "wblock-help-info")),
"WBLOCK Specify insertion base point:" => Some(("insert", "wblock-specify-insertion-base-point")),
"Auto Constrain" => Some(("common", "auto-constrain")),
"Delete Constraints" => Some(("common", "delete-constraints")),
"Dimensional" => Some(("common", "dimensional")),
@@ -265,6 +276,27 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Added annotation scale {arg}." => Some(("view", "added-annotation-scale-arg")),
"Added from GitHub" => Some(("plugins", "added-from-github")),
"Adjust" => Some(("insert", "adjust")),
"Attach External Reference" => Some(("insert", "xref-attach-dialog-title")),
"Browse..." => Some(("insert", "xref-attach-browse")),
"Reference Type" => Some(("insert", "xref-attach-reference-type")),
"Uniform Scale" => Some(("insert", "xref-attach-uniform-scale")),
"Insertion point" => Some(("insert", "xref-attach-insertion-point")),
"Angle:" => Some(("insert", "xref-attach-angle")),
"Block Unit" => Some(("insert", "xref-attach-block-unit")),
"Unit:" => Some(("insert", "xref-attach-unit")),
"Factor:" => Some(("insert", "xref-attach-factor")),
"Found in:" => Some(("insert", "xref-attach-found-in")),
"Saved path:" => Some(("insert", "xref-attach-saved-path")),
"Show Details" => Some(("insert", "xref-attach-show-details")),
"Hide Details" => Some(("insert", "xref-attach-hide-details")),
"Select Reference File" => Some(("insert", "xref-attach-select-file")),
"All Reference Files" => Some(("insert", "xref-attach-all-files")),
"Inserts references to external files such as other drawings, raster images, and underlays." => Some(("insert", "xref-attach-help")),
"Invalid input." => Some(("insert", "xref-attach-invalid-input")),
"Insertion point X" => Some(("insert", "xref-insertion-point-x")),
"Insertion point Y" => Some(("insert", "xref-insertion-point-y")),
"Insertion point Z" => Some(("insert", "xref-insertion-point-z")),
"Layer property overrides" => Some(("insert", "xref-layer-property-overrides")),
"Adjust Colors for Background" => Some(("properties", "adjust-colors-for-background")),
"Advanced" => Some(("properties", "advanced")),
"Alias" => Some(("command", "alias-title-case")),
@@ -2935,15 +2967,8 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static
"Underlay Layers" => Some(("insert", "pdfui-underlay-layers")),
"PDF Import Settings" => Some(("insert", "pdfui-pdf-import-settings")),
"Import PDF" => Some(("insert", "pdfui-import-pdf")),
"Browse..." => Some(("insert", "pdfui-browse")),
"Select one or more pages from the PDF file:" => Some(("insert", "pdfui-select-one-or-more-pages-from-the-pdf-file")),
"Insertion point" => Some(("insert", "pdfui-insertion-point")),
"Angle:" => Some(("insert", "pdfui-angle")),
"Found in:" => Some(("insert", "pdfui-found-in")),
"Saved path:" => Some(("insert", "pdfui-saved-path")),
"Page size:" => Some(("insert", "pdfui-page-size")),
"Hide Details" => Some(("insert", "pdfui-hide-details")),
"Show Details" => Some(("insert", "pdfui-show-details")),
"Reference name:" => Some(("insert", "pdfui-reference-name")),
"Search for layer" => Some(("insert", "pdfui-search-for-layer")),
"This file does not contain any layers." => Some(("insert", "pdfui-this-file-does-not-contain-any-layers")),
+1 -1
View File
@@ -119,7 +119,7 @@ fn main() -> iced::Result {
}
if let Some(io) = &args.export {
// clap enforces exactly two values for --export.
let code = app::export_headless(&io[0], &io[1]);
let code = app::export_headless(&io[0], &io[1], args.target_version.as_deref());
std::process::exit(code);
}
+27 -4
View File
@@ -22,7 +22,7 @@ const MODERN_PROTOCOL_VERSION: &str = "2026-07-28";
const MAX_REQUEST: usize = 1_048_576;
const MAX_RESPONSE: u64 = 16 * 1024 * 1024;
const CACHE_TTL_MS: u64 = 3_600_000;
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path.";
const INSTRUCTIONS: &str = "Call ocs_sessions, then pass its session_id as ocs_session_id to ocs_read, ocs_execute and ocs_capture. Read capabilities to discover the complete CAD automation surface. Call record_schema to discover every record type, property path, JSON type, enum, unit, constraint and write rule before editing unfamiliar data. Use records to inspect every serializable entity, object, table, header and document record; filter with RFC 6901 JSON Pointer paths. Use set_properties for atomic, type-checked record edits and preserve document_id, revision and request_id. Use commands with parameters.name for a command manifest. Use batch when several steps are known, and request changed_entities when resulting geometry is needed. For interactive work, call start and follow state.command.accepts, options and input_example. To have the person at the screen pick entities for you, call user_select and keep polling until it completes; running means they are still picking. A run.cmd contains the command name followed by prompt answers separated by spaces; points use x,y or x,y,z. After a timeout, query the existing operation and never replay a mutation with a new request_id. waiting_input and running are not completion. Let OCS and its geometry kernel calculate geometry; use query near, contains_point and intersections for exact relationships. Verify important results with queries and a viewport capture, and save only to an explicit path. Before delivery call audit with the intended target_format and target_version; use save_verified with an explicit absolute path to save, reopen, hash and compare the semantic manifest.";
const READ_OPS: &[&str] = &[
"state",
"hello",
@@ -40,6 +40,7 @@ const READ_OPS: &[&str] = &[
"events",
"operation",
"xdata_get",
"audit",
];
const EXECUTE_OPS: &[&str] = &[
"new",
@@ -77,6 +78,7 @@ const EXECUTE_OPS: &[&str] = &[
"page_setup_set",
"file_identity",
"save",
"save_verified",
"stop",
"batch",
];
@@ -394,7 +396,7 @@ impl GuiClient {
self.state["document_id"].clone(),
);
insert_default(&mut object, "revision", self.state["revision"].clone());
if ["input", "property", "run", "action", "save", "undo", "redo"].contains(&op.as_str())
if ["input", "property", "run", "action", "save", "save_verified", "undo", "redo"].contains(&op.as_str())
{
insert_default(&mut object, "selection", self.state["selection"].clone());
}
@@ -846,6 +848,12 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> {
r#"{"op":"page_setup_set","layout":"Plan","paper":"ISO_A4_(210.00_x_297.00_MM)"}"#,
)
}
"save_verified" if request["path"].as_str().is_none_or(str::is_empty) => {
missing(
"path",
r#"{"op":"save_verified","request_id":"deliver-1","path":"/absolute/output.dwg","target_version":"2018"}"#,
)
}
_ => Ok(()),
}
}
@@ -990,6 +998,9 @@ fn batch_step_schema() -> Value {
"document_id":{"type":"integer","minimum":0},
"cmd":{"type":"string","minLength":1},
"path":{"type":"string","minLength":1},
"target_format":{"type":"string","enum":["dwg","dxf"]},
"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"]},
"allow_lossy":{"type":"boolean","default":false},
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"]},
"text":{"type":"string"},
"point":{"type":"array","items":{"type":"number"},"minItems":3,"maxItems":3},
@@ -1031,7 +1042,7 @@ fn execute_request_schema() -> Value {
"camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."},
"selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."},
"cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]},
"path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot."},
"path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."},
"block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."},
"linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."},
"layout":{"type":"string","description":"plot: Model (default), a layout name, or all."},
@@ -1074,6 +1085,10 @@ fn execute_request_schema() -> Value {
"set":{"type":"object","description":"sysvar: name=value pairs to write in one undo step.","additionalProperties":true},
"select":{"type":"boolean","description":"selection_set_load: also select the recalled entities (default true)."},
"where":{"type":"array","description":"query: cross-property filters over entity properties with RFC 6901 paths and the records operator set.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string"},"value":{}},"required":["path"]}},
"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Explicit output format; it must match the path extension."},
"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Explicit CAD output version. Omit only to preserve the document version."},
"allow_lossy":{"type":"boolean","default":false,"description":"Acknowledge dropping unsupported passthrough records reported by audit."},
"overwrite":{"type":"boolean","default":false,"description":"For save_verified only: replace an existing destination."},
"at":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World [x,y] or [x,y,z] placement corner for embed_image (picture grows up-right)."},
"width":{"type":"number","exclusiveMinimum":0,"description":"World width for embed_image; height follows the image aspect ratio. Defaults to pixel_width/100."},
"kind":{"type":"string","enum":["text","token","point","entity","structure","selection","enter"],"description":"Input kind listed in state.command.accepts."},
@@ -1138,6 +1153,7 @@ fn execute_request_schema() -> Value {
{"properties":{"op":{"const":"page_setup_set"}},"required":["layout"]},
{"properties":{"op":{"const":"file_identity"}}},
{"properties":{"op":{"const":"save"}}},
{"properties":{"op":{"const":"save_verified"}},"required":["path"]},
{"properties":{"op":{"const":"stop"}}},
{"properties":{"op":{"const":"batch"}},"required":["steps"]}
]
@@ -1182,7 +1198,7 @@ fn tool_definitions() -> Value {
{
"name":"ocs_read",
"description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.",
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"document_id":{"type":"integer","minimum":0},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false},
"inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false},
"outputSchema":read_output_schema(),
"annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}
},
@@ -1552,7 +1568,14 @@ mod tests {
assert!(READ_OPS.contains(&"capabilities"));
assert!(READ_OPS.contains(&"records"));
assert!(READ_OPS.contains(&"record_schema"));
assert!(READ_OPS.contains(&"audit"));
assert!(EXECUTE_OPS.contains(&"set_properties"));
assert!(EXECUTE_OPS.contains(&"save_verified"));
assert_eq!(
tools[2]["inputSchema"]["properties"]["request"]["properties"]
["target_version"]["enum"][0],
"R14"
);
assert_eq!(
tools[1]["inputSchema"]["properties"]["parameters"]["properties"]["where"]["items"]["properties"]
["op"]["enum"],
+1
View File
@@ -25,6 +25,7 @@ mod underlay_layers;
pub(crate) mod wblock;
mod xadjust;
pub(crate) mod xattach;
pub(crate) mod xref_cmd;
mod xclip;
use crate::modules::{CadModule, IconKind, RibbonGroup, RibbonItem};
+106
View File
@@ -157,3 +157,109 @@ pub fn normalize_to_origin(out: &mut CadDocument) {
crate::scene::view::dispatch::apply_transform(entity, &shift);
}
}
/// One-shot point picker for the Write Block dialog's "Pick point" button.
pub struct WblockPickBasePointCommand;
impl crate::command::CadCommand for WblockPickBasePointCommand {
fn name(&self) -> &'static str {
"WBLOCK"
}
fn prompt(&self) -> String {
crate::t!("WBLOCK Specify insertion base point:").into_owned()
}
fn on_point(&mut self, pt: glam::DVec3) -> crate::command::CmdResult {
crate::command::CmdResult::Dispatch(format!("WBLOCK_POINT_PICKED {} {} {}", pt.x, pt.y, pt.z))
}
fn on_enter(&mut self) -> crate::command::CmdResult {
crate::command::CmdResult::Dispatch("WBLOCK_POINT_CANCELLED".to_string())
}
fn on_escape(&mut self) -> crate::command::CmdResult {
crate::command::CmdResult::Dispatch("WBLOCK_POINT_CANCELLED".to_string())
}
}
/// Build a standalone `CadDocument` from an explicit list of entity handles,
/// applying `base_point` translation and `unit` insertion units.
pub fn extract_entities_to_doc_with_base(
src: &CadDocument,
handles: &[acadrust::Handle],
base_point: glam::DVec3,
unit: i16,
) -> Result<CadDocument, String> {
let mut out = CadDocument::new();
out.header.insertion_units = unit;
extract_entities_into(src, handles, &mut out)?;
if base_point != glam::DVec3::ZERO {
let shift = crate::command::EntityTransform::Affine(
acadrust::types::Transform::from_translation(acadrust::types::Vector3::new(
-base_point.x,
-base_point.y,
-base_point.z,
)),
);
for entity in out.entities_mut() {
crate::scene::view::dispatch::apply_transform(entity, &shift);
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use acadrust::entities::Line;
use acadrust::types::Vector3;
#[test]
fn test_extract_entities_with_base_point_and_units() {
let mut doc = CadDocument::new();
let mut line = Line::new();
line.start = Vector3::new(10.0, 20.0, 0.0);
line.end = Vector3::new(30.0, 40.0, 0.0);
let h = doc.add_entity(EntityType::Line(line)).unwrap();
let out = extract_entities_to_doc_with_base(
&doc,
&[h],
glam::DVec3::new(10.0, 20.0, 0.0),
4, // Millimeters
)
.expect("extract should succeed");
assert_eq!(out.header.insertion_units, 4);
assert_eq!(out.entities().count(), 1);
let e = out.entities().next().unwrap();
if let EntityType::Line(l) = e {
assert!((l.start.x - 0.0).abs() < 1e-6);
assert!((l.start.y - 0.0).abs() < 1e-6);
assert!((l.end.x - 20.0).abs() < 1e-6);
assert!((l.end.y - 20.0).abs() < 1e-6);
} else {
panic!("Expected Line entity");
}
}
#[test]
fn test_extract_block_to_doc() {
let mut doc = CadDocument::new();
let mut line = Line::new();
line.start = Vector3::new(1.0, 2.0, 0.0);
line.end = Vector3::new(3.0, 4.0, 0.0);
let h = doc.add_entity(EntityType::Line(line)).unwrap();
let mut block_record = acadrust::tables::BlockRecord::new("MY_BLOCK".to_string());
block_record.entity_handles.push(h);
let _ = doc.block_records.add(block_record);
let out = extract_block_to_doc(&doc, "MY_BLOCK").expect("block extract should succeed");
assert_eq!(out.entities().count(), 1);
assert!(extract_block_to_doc(&doc, "NON_EXISTENT").is_err());
}
}
File diff suppressed because it is too large. Load diff
+237
View File
@@ -0,0 +1,237 @@
// -XREF — the command-line form of the reference manager.
//
// Enter an option [?/Bind/Detach/Path/pathType/Unload/Reload/Overlay/Attach/Show] <Attach>:
//
// Each option collects its answers with the reference prompts and hands them
// to the existing reference operations through `CmdResult::Dispatch`.
use glam::DVec3;
use crate::command::{CadCommand, CmdOption, CmdResult, InputKind};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Step {
Option,
List,
Names(&'static str),
File { overlay: bool },
PathNames,
NewPath { name: String },
PathTypeNames,
PathTypeKind { names: String },
ShowNames,
}
pub struct XrefCommand {
step: Step,
/// Saved paths by reference name, for the Path option's "Old path:" line.
saved_paths: Vec<(String, String)>,
}
impl XrefCommand {
pub fn new(saved_paths: Vec<(String, String)>) -> Self {
Self {
step: Step::Option,
saved_paths,
}
}
/// Whether any reference name matches `pattern` (`*` / `?` wildcards).
fn matches(&self, pattern: &str) -> bool {
self.saved_paths
.iter()
.any(|(name, _)| crate::io::xref_model::wildcard_match(name, pattern))
}
fn choose(&mut self, token: &str) -> CmdResult {
self.step = match token {
"?" => Step::List,
"B" | "BIND" => Step::Names("Bind"),
"D" | "DETACH" => Step::Names("Detach"),
"P" | "PATH" => Step::PathNames,
"T" | "PATHTYPE" => Step::PathTypeNames,
"U" | "UNLOAD" => Step::Names("Unload"),
"R" | "RELOAD" => Step::Names("Reload"),
"O" | "OVERLAY" => Step::File { overlay: true },
"A" | "ATTACH" => Step::File { overlay: false },
"S" | "SHOW" => Step::ShowNames,
_ => {
return CmdResult::ReportError("Invalid option keyword.".to_string());
}
};
CmdResult::NeedPoint
}
}
impl CadCommand for XrefCommand {
fn name(&self) -> &'static str {
"-XREF"
}
fn prompt(&self) -> String {
let text = match &self.step {
Step::Option => "Enter an option [?/Bind/Detach/Path/pathType/Unload/Reload/Overlay/Attach/Show] <Attach>:",
Step::List => "Enter xref name(s) to list <*>:",
Step::Names("Bind") => "Enter xref name(s) to bind:",
Step::Names("Detach") => "Enter xref name(s) to detach:",
Step::Names("Unload") => "Enter xref name(s) to unload:",
Step::Names(_) => "Enter xref name(s) to reload:",
Step::File { overlay: false } => "Enter name of file to attach:",
Step::File { overlay: true } => "Enter name of file to overlay:",
Step::PathNames => "Edit xref name(s) to edit path:",
Step::NewPath { .. } => "Enter new path:",
Step::PathTypeNames => "Enter xref name(s) to edit path type:",
Step::PathTypeKind { .. } => "Enter new path type [Full/Relative/None]:",
Step::ShowNames => "Enter xref name(s) to show:",
};
format!("-XREF {text}")
}
fn options(&self) -> Vec<CmdOption> {
match self.step {
Step::Option => vec![
CmdOption::new("?", "?"),
CmdOption::new("Bind", "B"),
CmdOption::new("Detach", "D"),
CmdOption::new("Path", "P"),
CmdOption::new("pathType", "T"),
CmdOption::new("Unload", "U"),
CmdOption::new("Reload", "R"),
CmdOption::new("Overlay", "O"),
CmdOption::new("Attach", "A"),
CmdOption::new("Show", "S"),
],
Step::PathTypeKind { .. } => vec![
CmdOption::new("Full", "F"),
CmdOption::new("Relative", "R"),
CmdOption::new("None", "N"),
],
_ => Vec::new(),
}
}
fn input_kind(&self) -> InputKind {
match self.step {
// Paths and names can hold spaces.
Step::File { .. } | Step::NewPath { .. } => InputKind::FreeText,
_ => InputKind::SingleToken,
}
}
fn on_point(&mut self, _pt: DVec3) -> CmdResult {
CmdResult::NeedPoint
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let value = text.trim().trim_matches('"').to_string();
let token = value.to_uppercase();
let names_step = matches!(
self.step,
Step::Names(_) | Step::PathTypeNames | Step::ShowNames
);
if names_step && !self.matches(&value) {
return Some(CmdResult::Measurement(
"No matching xref names found.".to_string(),
));
}
Some(match self.step.clone() {
Step::Option => self.choose(&token),
Step::List => CmdResult::Dispatch(format!("XREFLIST {value}")),
Step::Names(op) => CmdResult::Dispatch(format!("XREF {op} {value}")),
Step::File { overlay } => {
let verb = if overlay { "XREFOVERLAYFILE" } else { "XREFATTACHFILE" };
CmdResult::Dispatch(format!("{verb} {value}"))
}
Step::PathNames => {
let Some((name, old)) = self
.saved_paths
.iter()
.find(|(name, _)| crate::io::xref_model::wildcard_match(name, &value))
.cloned()
else {
return Some(CmdResult::Measurement(
"No matching xref names found.".to_string(),
));
};
self.step = Step::NewPath { name: name.clone() };
CmdResult::ReportMeasurement(format!(
"{}\n{}",
format!("xref name: \"{}\"", name),
format!("Old path: {}", display_path(&old))
))
}
Step::NewPath { name } => CmdResult::Dispatch(format!("XREF Path {name} {value}")),
Step::PathTypeNames => {
self.step = Step::PathTypeKind { names: value };
CmdResult::NeedPoint
}
Step::PathTypeKind { names } => {
let kind = match token.as_str() {
"F" | "FULL" => "Full",
"R" | "RELATIVE" => "Relative",
"N" | "NONE" => "None",
_ => {
return Some(CmdResult::ReportError(
"Invalid option keyword.".to_string(),
))
}
};
CmdResult::Dispatch(format!("XREF Pathtype {kind} {names}"))
}
Step::ShowNames => CmdResult::Dispatch("XREFSHOW".to_string()),
})
}
fn on_enter(&mut self) -> CmdResult {
match self.step.clone() {
Step::Option => self.choose("A"),
Step::List => CmdResult::Dispatch("XREFLIST *".to_string()),
Step::NewPath { .. } => {
CmdResult::Measurement("Path unchanged.".to_string())
}
Step::PathTypeKind { .. } => CmdResult::NeedPoint,
_ => CmdResult::Cancel,
}
}
}
/// A stored path shown the way the platform writes it.
pub fn display_path(path: &str) -> String {
if cfg!(windows) {
path.replace('/', "\\")
} else {
path.to_string()
}
}
#[cfg(test)]
mod tests {
use super::XrefCommand;
use crate::command::{CadCommand, CmdResult};
#[test]
fn enter_chooses_attach() {
let mut cmd = XrefCommand::new(Vec::new());
assert!(matches!(cmd.on_enter(), CmdResult::NeedPoint));
assert!(cmd.prompt().contains("Enter name of file to attach:"));
}
#[test]
fn list_defaults_to_every_reference() {
let mut cmd = XrefCommand::new(Vec::new());
cmd.on_text_input("?");
assert!(cmd.prompt().contains("Enter xref name(s) to list <*>:"));
assert!(matches!(cmd.on_enter(), CmdResult::Dispatch(ref s) if s == "XREFLIST *"));
}
#[test]
fn overlay_file_is_dispatched() {
let mut cmd = XrefCommand::new(Vec::new());
cmd.on_text_input("O");
assert!(cmd.prompt().contains("Enter name of file to overlay:"));
assert!(matches!(
cmd.on_text_input("C:/refs/site plan.dwg"),
Some(CmdResult::Dispatch(ref s)) if s == "XREFOVERLAYFILE C:/refs/site plan.dwg"
));
}
}
+24
View File
@@ -1428,6 +1428,19 @@ impl Scene {
sy += y as f64;
n += 1;
}
// SDF text is carried as textured glyph quads, not ordinary wire
// points. Include those vertices so a text-only wire participates
// in the robust-centre filter used by ZOOM EXTENTS.
for vertex in &wire.text_verts {
let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64;
let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64;
if !x.is_finite() || !y.is_finite() {
continue;
}
sx += x;
sy += y;
n += 1;
}
if n > 0 {
cents.push(WireCent {
idx,
@@ -1486,6 +1499,17 @@ impl Scene {
max = max.max(glam::Vec3::new(x, y, z));
}
}
for vertex in &wire.text_verts {
let point = glam::Vec3::new(
vertex.pos[0] + vertex.pos_low[0],
vertex.pos[1] + vertex.pos_low[1],
vertex.pos[2] + vertex.pos_low[2],
);
if point.is_finite() {
min = min.min(point);
max = max.max(point);
}
}
}
// Fold in 3D-solid mesh AABBs (not subject to the wire IQR reject).
for ([ax, ay, bx, by], [az, bz]) in &mesh_aabbs {
+2 -2
View File
@@ -2884,11 +2884,11 @@ impl Scene {
/// standard linetypes and a compatible page setup on every paper layout.
pub fn populate_new_drawing_defaults(&mut self) {
crate::io::linetypes::populate_document(&mut self.document);
// A new drawing is metric, as the reference's ISO template: millimetre
// A new drawing is metric, as the reference's new drawing: metre
// insertion units, metric measurement, and the ISO-25 dimension style
// current. Standard stays available for imperial work.
self.document.header.measurement = 1;
self.document.header.insertion_units = 4;
self.document.header.insertion_units = 6;
let iso = crate::scene::creation_style::ensure_iso_dim_style(&mut self.document);
self.document.header.current_dimstyle_name = "ISO-25".to_string();
// The name alone leaves the header pointing at the previous style, and
+22
View File
@@ -633,6 +633,28 @@ impl WireModel {
out
}
/// Return a preview clone with every point mapped through `map` (an
/// INSERT-style placement: per-axis scale, rotation, translation).
/// Tangent geometry is dropped; a preview is not snapped to.
pub fn mapped(&self, map: impl Fn(glam::DVec3) -> glam::DVec3) -> Self {
let mut out = self.clone();
out.name = format!("preview_{}", self.name);
out.color = Self::CYAN;
out.selected = false;
map_points(&mut out.points, &mut out.points_low, &map);
if let Some(marker) = &mut out.point_marker {
marker.origin = map(marker.origin);
}
if !out.text_verts.is_empty() {
out.text_verts = map_text_verts(&self.text_verts, |x, y, z| {
let p = map(glam::DVec3::new(x, y, z));
(p.x, p.y, p.z)
});
}
out.tangent_geoms.clear();
out
}
/// Return a clone with every point rotated around `center` by `angle_rad`.
pub fn rotated(&self, center: glam::Vec3, angle_rad: f32) -> Self {
self.rotated_about_axis(center, glam::Vec3::Z, angle_rad)
+4 -1
View File
@@ -2507,7 +2507,9 @@ impl super::Scene {
}
/// The document as written to a file: a dynamic dimension's screen-size
/// overrides are a display matter and stay out of the file.
/// overrides are a display matter and stay out of the file, and so does
/// what resolving an external reference merged in (its geometry, nested
/// blocks and styles), which the file never stores.
pub(crate) fn document_for_save(&self) -> acadrust::CadDocument {
use crate::entities::dim_override as ov;
let mut document = self.document.clone();
@@ -2530,6 +2532,7 @@ impl super::Scene {
}
}
}
crate::io::xref::strip_resolved_xref_content(&mut document);
document
}
+35 -1
View File
@@ -7,10 +7,44 @@
use crate::app::Message;
use crate::ui::style::common::muted_style;
use iced::widget::{button, container, pick_list, row, text, text_input, Space};
use iced::widget::{button, container, pick_list, radio, row, text, text_input, Space};
use iced::{Background, Border, Element, Length, Theme};
use std::borrow::Cow;
/// Standard dialog footer action button (OK, Cancel, Apply, Close, Help, etc.),
/// matching the Drafting Settings / Options appearance: 12px text, [6, 18] padding,
/// themed primary or secondary style.
pub fn dialog_button<'a>(
label: impl text::IntoFragment<'a>,
on_press: Message,
primary: bool,
) -> button::Button<'a, Message> {
button(text(label).size(12))
.on_press(on_press)
.padding([6, 18])
.style(if primary {
button::primary
} else {
button::secondary
})
}
/// Unified radio button helper ensuring consistent 16px size for exact pixel-grid concentric alignment.
pub fn form_radio<'a, T>(
label: impl Into<String>,
value: T,
selected: Option<T>,
on_click: impl Fn(T) -> Message + 'a,
) -> radio::Radio<'a, Message>
where
T: Copy + Eq + 'a,
{
radio(label, value, selected, on_click)
.size(16)
.spacing(6)
.text_size(11)
}
/// Width of the label column in a `label : control` row.
pub const LABEL_WIDTH: f32 = 92.0;
+2 -2
View File
@@ -168,7 +168,7 @@ pub fn view_window<'a>(
Some(relative),
Message::PointStyleSizeRelative,
)
.size(15)
.size(16)
.text_size(13),
radio(
t!("Set Size in Absolute Units"),
@@ -176,7 +176,7 @@ pub fn view_window<'a>(
Some(relative),
Message::PointStyleSizeRelative,
)
.size(15)
.size(16)
.text_size(13),
]
.spacing(6);
+14 -38
View File
@@ -5,7 +5,7 @@ use std::fmt;
use acadrust::Handle;
use iced::widget::{
button, checkbox, column, combo_box, container, mouse_area, pick_list, radio, row, stack, text,
button, checkbox, column, combo_box, container, mouse_area, pick_list, row, stack, text,
text_editor, text_input, Space,
};
use iced::{Background, Border, Element, Fill, Length, Theme};
@@ -14,7 +14,7 @@ use crate::app::Message;
use crate::modules::draw::units;
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, field_style};
use crate::ui::style::form::{button_style, dialog_button, field_style, form_radio};
static ICON_PICK_POINT: &[u8] = include_bytes!("../../../assets/icons/blocks/pick_point.svg");
static ICON_SELECT_OBJECTS: &[u8] =
@@ -353,33 +353,24 @@ pub fn view_window<'a>(
let btns_row = row![sel_obj_btn, qsel_btn].spacing(5).align_y(iced::Center);
let radios = column![
radio(
form_radio(
t!("Retain"),
BlockObjectMode::Retain,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
radio(
),
form_radio(
t!("Convert to block"),
BlockObjectMode::Convert,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
radio(
),
form_radio(
t!("Delete"),
BlockObjectMode::Delete,
Some(state.object_mode),
Message::BlockDefObjectMode,
)
.size(15)
.spacing(5)
.text_size(11),
),
]
.spacing(2);
@@ -580,20 +571,11 @@ pub fn view_window<'a>(
// ── Footer Bar ────────────────────────────────────────────────────────────
let actions = row![
Space::new().width(Fill),
button(text(t!("OK")).size(11))
.on_press(Message::BlockDefApply)
.style(button_style(true))
.padding([4, 16]),
button(text(t!("Cancel")).size(11))
.on_press(Message::CloseModal)
.style(button_style(false))
.padding([4, 12]),
button(text(t!("Help")).size(11))
.on_press(Message::BlockDefHelp)
.style(button_style(false))
.padding([4, 12]),
dialog_button(t!("OK"), Message::BlockDefApply, true),
dialog_button(t!("Cancel"), Message::CloseModal, false),
dialog_button(t!("Help"), Message::BlockDefHelp, false),
]
.spacing(6)
.spacing(8)
.align_y(iced::Center);
// ── Error Banner ──────────────────────────────────────────────────────────
@@ -664,15 +646,9 @@ pub fn view_window<'a>(
.align_x(iced::alignment::Horizontal::Center),
Space::new().height(16),
row![
button(text(t!("Yes")).size(12))
.on_press(Message::BlockDefConfirmRedefine(true))
.padding([6, 20])
.style(button_style(false)),
dialog_button(t!("Yes"), Message::BlockDefConfirmRedefine(true), false),
// Default / safe focus on "No"
button(text(t!("No")).size(12))
.on_press(Message::BlockDefConfirmRedefine(false))
.padding([6, 22])
.style(button_style(true)),
dialog_button(t!("No"), Message::BlockDefConfirmRedefine(false), true),
]
.spacing(12)
.align_y(iced::Center),
+15 -6
View File
@@ -17,6 +17,7 @@ fn muted_style(theme: &Theme) -> text::Style {
pub fn view_window<'a>(
fonts: &'a [String],
font_source: &'a str,
downloading: bool,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let content_width = if matches!(sizing.width, Length::Fill) {
@@ -33,16 +34,24 @@ pub fn view_window<'a>(
.iter()
.map(|name| text(format!(" {name}")).size(12).into())
.collect();
let download = button(
text(if downloading {
crate::t!("Downloading...")
} else {
crate::t!("Download available")
})
.size(12),
)
.on_press_maybe((!downloading).then_some(Message::MissingFontsDownload))
.style(button::primary)
.padding([6, 14]);
let actions = row![
Space::new().width(content_width),
button(text(crate::t!("Skip")).size(12))
.on_press(Message::MissingFontsDismiss)
.on_press_maybe((!downloading).then_some(Message::MissingFontsDismiss))
.style(button::secondary)
.padding([6, 14]),
button(text(crate::t!("Download")).size(12))
.on_press(Message::MissingFontsDownload)
.style(button::primary)
.padding([6, 14]),
download,
]
.spacing(8);
@@ -50,7 +59,7 @@ pub fn view_window<'a>(
column![
text(crate::t!("Missing fonts")).size(20),
text(crate::t!(
"This drawing uses fonts that are not installed on this machine. Download them from the OpenCADStudio community font repository?"
"This drawing uses fonts that are not installed on this machine. OpenCADStudio can download any redistributable fonts that exist in the selected repository; unavailable fonts will keep using a substitute."
))
.size(11)
.style(muted_style),
+2
View File
@@ -25,5 +25,7 @@ pub mod alias_editor;
pub mod find_replace;
pub mod named_parameters;
pub mod pdf_dialogs;
pub mod xref_attach;
pub mod xref_help;
pub mod xref_manager;
pub mod wblock;
+492
View File
@@ -0,0 +1,492 @@
//! Write Block (WBLOCK) dialog window — write a block, selected entities, or entire drawing to an external DWG/DXF file.
use std::fmt;
use acadrust::Handle;
use iced::widget::{
button, column, combo_box, container, pick_list, row, text, text_input, Space,
};
use iced::{Background, Border, Element, Fill, Length, Theme};
use crate::app::Message;
use crate::modules::draw::units;
use crate::t;
use crate::ui::style::form::{button_style, dialog_button, field_style, form_radio};
static ICON_PICK_POINT: &[u8] = include_bytes!("../../../assets/icons/blocks/pick_point.svg");
static ICON_SELECT_OBJECTS: &[u8] =
include_bytes!("../../../assets/icons/blocks/select_objects.svg");
static ICON_QUICK_SELECT: &[u8] = include_bytes!("../../../assets/icons/blocks/quick_select.svg");
static ICON_WARN: &[u8] = include_bytes!("../../../assets/icons/ui/warning_triangle.svg");
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WblockSourceMode {
Block,
EntireDrawing,
Objects,
}
impl Default for WblockSourceMode {
fn default() -> Self {
WblockSourceMode::Objects
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum WblockObjectMode {
Retain,
Convert,
Delete,
}
impl Default for WblockObjectMode {
fn default() -> Self {
WblockObjectMode::Retain
}
}
/// Dropdown entry for the Unit picker.
#[derive(Clone, PartialEq, Eq)]
pub struct UnitChoice {
pub code: i16,
pub label: &'static str,
}
impl fmt::Display for UnitChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = crate::i18n::translate(self.label);
f.write_str(name.as_ref())
}
}
/// The Write Block dialog's state.
pub struct WblockState {
pub source_mode: WblockSourceMode,
pub block_name: String,
pub existing_blocks: Vec<String>,
pub block_combo: combo_box::State<String>,
pub base_point_x: String,
pub base_point_y: String,
pub base_point_z: String,
pub selected_handles: Vec<Handle>,
pub object_mode: WblockObjectMode,
pub file_path: String,
pub unit: i16,
pub error_message: Option<String>,
}
impl Clone for WblockState {
fn clone(&self) -> Self {
Self {
source_mode: self.source_mode,
block_name: self.block_name.clone(),
existing_blocks: self.existing_blocks.clone(),
block_combo: self.block_combo.clone(),
base_point_x: self.base_point_x.clone(),
base_point_y: self.base_point_y.clone(),
base_point_z: self.base_point_z.clone(),
selected_handles: self.selected_handles.clone(),
object_mode: self.object_mode,
file_path: self.file_path.clone(),
unit: self.unit,
error_message: self.error_message.clone(),
}
}
}
impl WblockState {
pub fn new(
existing_blocks: Vec<String>,
pre_selected: Vec<Handle>,
default_unit: i16,
default_path: String,
) -> Self {
let default_block = existing_blocks.first().cloned().unwrap_or_default();
Self {
source_mode: WblockSourceMode::Objects,
block_name: default_block,
block_combo: combo_box::State::new(existing_blocks.clone()),
existing_blocks,
base_point_x: "0.00".to_string(),
base_point_y: "0.00".to_string(),
base_point_z: "0.00".to_string(),
selected_handles: pre_selected,
object_mode: WblockObjectMode::Retain,
file_path: default_path,
unit: default_unit,
error_message: None,
}
}
pub fn parse_base_point(&self) -> glam::DVec3 {
let x = self.base_point_x.trim().parse::<f64>().unwrap_or(0.0);
let y = self.base_point_y.trim().parse::<f64>().unwrap_or(0.0);
let z = self.base_point_z.trim().parse::<f64>().unwrap_or(0.0);
glam::DVec3::new(x, y, z)
}
}
fn group<'a>(
title: impl Into<String>,
body: impl Into<Element<'a, Message>>,
height: impl Into<Length>,
) -> Element<'a, Message> {
container(
column![
text(title.into())
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
}),
body.into(),
]
.spacing(4),
)
.padding([5, 8])
.width(Fill)
.height(height)
.style(|theme: &Theme| container::Style {
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
})
.into()
}
pub fn view_window<'a>(
state: &'a WblockState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
let is_objects = state.source_mode == WblockSourceMode::Objects;
let is_block = state.source_mode == WblockSourceMode::Block;
// ── Source: Block Radio & Dropdown ─────────────────────────────────────────
let block_radio = form_radio(
t!("Block:"),
WblockSourceMode::Block,
Some(state.source_mode),
Message::WblockSourceMode,
);
let block_dropdown: Element<'a, Message> = if is_block {
combo_box(
&state.block_combo,
"",
if state.block_name.is_empty() {
None
} else {
Some(&state.block_name)
},
|chosen: String| Message::WblockBlockSelect(chosen),
)
.on_input(Message::WblockBlockName)
.size(11)
.padding([2, 5])
.width(Fill)
.into()
} else {
crate::ui::read_only::field(&state.block_name, 11.0, Fill)
};
let block_row = row![block_radio, block_dropdown]
.spacing(8)
.width(Fill)
.align_y(iced::Center);
let entire_drawing_radio = form_radio(
t!("Entire drawing"),
WblockSourceMode::EntireDrawing,
Some(state.source_mode),
Message::WblockSourceMode,
);
let objects_radio = form_radio(
t!("Objects"),
WblockSourceMode::Objects,
Some(state.source_mode),
Message::WblockSourceMode,
);
// ── Source: Base Point Sub-group ──────────────────────────────────────────
let pick_pt_icon = crate::ui::icons::semantic(ICON_PICK_POINT, 14.0);
let mut pick_pt_btn = button(
row![pick_pt_icon, text(t!("Pick point")).size(11)]
.spacing(6)
.align_y(iced::Center),
)
.padding([3, 10]);
if is_objects {
pick_pt_btn = pick_pt_btn
.on_press(Message::WblockPickPoint)
.style(button_style(false));
} else {
pick_pt_btn = pick_pt_btn.style(button_style(false));
}
let coord_row = |axis: &'static str, val: &'a str, on_input: fn(String) -> Message| {
let input: Element<'a, Message> = if is_objects {
text_input("", val)
.on_input(on_input)
.on_submit(Message::WblockApply)
.style(field_style)
.size(11)
.padding([2, 5])
.width(Fill)
.into()
} else {
crate::ui::read_only::field(val, 11.0, Fill)
};
row![
text(axis)
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
})
.width(Length::Fixed(16.0)),
input,
]
.spacing(4)
.align_y(iced::Center)
};
let base_point_group = group(
t!("Base point").into_owned(),
column![
pick_pt_btn,
Space::new().height(3),
coord_row("X:", &state.base_point_x, Message::WblockBaseX),
coord_row("Y:", &state.base_point_y, Message::WblockBaseY),
coord_row("Z:", &state.base_point_z, Message::WblockBaseZ),
]
.spacing(3),
Length::Fixed(142.0),
);
// ── Source: Objects Sub-group ─────────────────────────────────────────────
let sel_obj_icon = crate::ui::icons::semantic(ICON_SELECT_OBJECTS, 14.0);
let mut sel_obj_btn = button(
row![sel_obj_icon, text(t!("Select objects")).size(11)]
.spacing(6)
.align_y(iced::Center),
)
.padding([3, 8]);
let qsel_icon = crate::ui::icons::semantic(ICON_QUICK_SELECT, 14.0);
let mut qsel_btn = button(qsel_icon).padding([3, 6]);
if is_objects {
sel_obj_btn = sel_obj_btn
.on_press(Message::WblockSelectObjects)
.style(button_style(false));
qsel_btn = qsel_btn
.on_press(Message::WblockQuickSelect)
.style(button_style(false));
} else {
sel_obj_btn = sel_obj_btn.style(button_style(false));
qsel_btn = qsel_btn.style(button_style(false));
}
let obj_btns_row = row![sel_obj_btn, qsel_btn]
.spacing(5)
.align_y(iced::Center);
let object_radios = column![
form_radio(
t!("Retain"),
WblockObjectMode::Retain,
if is_objects { Some(state.object_mode) } else { None },
Message::WblockObjectMode,
),
form_radio(
t!("Convert to block"),
WblockObjectMode::Convert,
if is_objects { Some(state.object_mode) } else { None },
Message::WblockObjectMode,
),
form_radio(
t!("Delete from drawing"),
WblockObjectMode::Delete,
if is_objects { Some(state.object_mode) } else { None },
Message::WblockObjectMode,
),
]
.spacing(3);
let count = state.selected_handles.len();
let status_row: Element<'a, Message> = if !is_objects {
Space::new().height(14).into()
} else if count == 0 {
row![
crate::ui::icons::semantic(ICON_WARN, 13.0),
text(t!("No objects selected"))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().warning.base.color),
}),
]
.spacing(5)
.align_y(iced::Center)
.into()
} else {
row![text(crate::tf!("{count} objects selected"))
.size(10)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().primary.base.color),
}),]
.spacing(5)
.align_y(iced::Center)
.into()
};
let objects_subgroup = group(
t!("Objects").into_owned(),
column![obj_btns_row, Space::new().height(2), object_radios, Space::new().height(2), status_row,]
.spacing(2),
Length::Fixed(142.0),
);
let sub_panels = row![base_point_group, objects_subgroup]
.spacing(8)
.width(Fill);
let source_group = group(
t!("Source").into_owned(),
column![
block_row,
entire_drawing_radio,
objects_radio,
Space::new().height(4),
sub_panels,
]
.spacing(4),
Length::Shrink,
);
// ── Destination Group ─────────────────────────────────────────────────────
let file_input = text_input("", &state.file_path)
.on_input(Message::WblockFilePath)
.on_submit(Message::WblockApply)
.style(field_style)
.size(11)
.padding([3, 6])
.width(Fill);
let browse_btn = button(
container(text("...").size(11))
.width(Fill)
.align_x(iced::alignment::Horizontal::Center),
)
.width(Length::Fixed(28.0))
.on_press(Message::WblockBrowsePath)
.style(button::secondary)
.padding([3, 0]);
let path_row = row![file_input, browse_btn].spacing(5).align_y(iced::Center);
let unit_options: Vec<UnitChoice> = units::all()
.map(|(code, label)| UnitChoice { code, label })
.collect();
let selected_unit = unit_options
.iter()
.find(|choice| choice.code == state.unit)
.cloned();
let unit_picker = pick_list(selected_unit, unit_options, |choice| choice.to_string())
.on_select(|choice| Message::WblockUnit(choice.code))
.text_size(11)
.padding([2, 5])
.width(Fill);
let units_row = row![
text(t!("Insert units:"))
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
}),
Space::new().width(8),
unit_picker,
Space::new().width(Length::Fixed(33.0)),
]
.spacing(4)
.align_y(iced::Center);
let destination_group = group(
t!("Destination").into_owned(),
column![
text(t!("File name and path:"))
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text.scale_alpha(0.85)),
}),
path_row,
Space::new().height(4),
units_row,
]
.spacing(3),
Length::Shrink,
);
// ── Footer Bar ────────────────────────────────────────────────────────────
let actions = row![
Space::new().width(Fill),
dialog_button(t!("OK"), Message::WblockApply, true),
dialog_button(t!("Cancel"), Message::CloseModal, false),
dialog_button(t!("Help"), Message::WblockHelp, false),
]
.spacing(8)
.align_y(iced::Center);
// ── Error Banner ──────────────────────────────────────────────────────────
let mut main_column = column![source_group, destination_group, actions,]
.spacing(8)
.padding([8, 10])
.width(sizing.width);
if let Some(err) = &state.error_message {
let err_banner = container(
row![
crate::ui::icons::semantic(ICON_WARN, 16.0),
text(err.clone())
.size(11)
.style(|theme: &Theme| text::Style {
color: Some(theme.palette().background.strongest.text),
}),
Space::new().width(Fill),
button(text("×").size(12))
.on_press(Message::WblockDismissError)
.style(button_style(false))
.padding([1, 6]),
]
.spacing(8)
.align_y(iced::Center),
)
.padding([6, 10])
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(
theme.palette().danger.base.color.scale_alpha(0.18),
)),
border: Border {
width: 1.0,
radius: 4.0.into(),
color: theme.palette().danger.base.color,
},
..Default::default()
});
main_column = column![err_banner, main_column]
.spacing(6)
.width(sizing.width)
.padding(10);
}
main_column.into()
}
+414
View File
@@ -0,0 +1,414 @@
//! Attach External Reference dialog — the options chosen after picking a
//! drawing with ATTACH / XATTACH: reference type, scale, insertion point,
//! path type and rotation, with the file's preview and block unit.
use std::fmt;
use iced::widget::{
button, column, combo_box, container, image, pick_list, radio, row, text, text_input, Space,
};
use iced::{Element, Fill, Length, Theme};
use crate::app::Message;
use crate::io::xref_model::Pathtype;
use crate::t;
use crate::ui::style::common::muted_style;
use crate::ui::style::form::{button_style, field_style};
use crate::ui::window::block_definition::{group, labeled_checkbox};
/// One edit in the dialog.
#[derive(Debug, Clone)]
pub enum XrefAttachMsg {
/// Name box: a reference already in the drawing, picked or typed.
Name(String),
Browse,
Overlay(bool),
ScaleOnScreen(bool),
Scale(usize, String),
Uniform(bool),
InsertOnScreen(bool),
Insert(usize, String),
PathType(PathTypeChoice),
RotationOnScreen(bool),
Rotation(String),
Details(bool),
Apply,
Help,
}
/// Path type choice as the dialog lists it.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PathTypeChoice(pub Pathtype);
impl PathTypeChoice {
pub const ALL: [PathTypeChoice; 3] = [
PathTypeChoice(Pathtype::Full),
PathTypeChoice(Pathtype::Relative),
PathTypeChoice(Pathtype::None),
];
}
impl fmt::Display for PathTypeChoice {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let label = match self.0 {
Pathtype::Full => t!("Full path"),
Pathtype::Relative => t!("Relative path"),
Pathtype::None => t!("No path"),
};
f.write_str(label.as_ref())
}
}
/// The dialog's working copy.
#[derive(Clone)]
pub struct XrefAttachState {
/// Absolute path of the drawing to attach.
pub path: String,
/// Reference name (the file name without extension).
pub name: String,
/// References already in the drawing: name and saved path.
pub existing: Vec<(String, String)>,
pub name_combo: combo_box::State<String>,
pub overlay: bool,
pub scale_on_screen: bool,
pub scale: [String; 3],
pub uniform: bool,
pub insert_on_screen: bool,
pub insert: [String; 3],
pub path_type: PathTypeChoice,
pub rotation_on_screen: bool,
pub rotation: String,
/// Block unit of the chosen drawing and its factor to the host's unit.
pub unit_label: String,
pub unit_factor: String,
pub found_in: String,
pub saved_path: String,
pub details: bool,
pub preview: Option<image::Handle>,
}
impl XrefAttachState {
pub fn new(existing: Vec<(String, String)>) -> Self {
let names = existing.iter().map(|(name, _)| name.clone()).collect();
Self {
path: String::new(),
name: String::new(),
existing,
name_combo: combo_box::State::new(names),
overlay: false,
scale_on_screen: false,
scale: ["1.0000".into(), "1.0000".into(), "1.0000".into()],
uniform: false,
insert_on_screen: true,
insert: ["0.0000".into(), "0.0000".into(), "0.0000".into()],
path_type: PathTypeChoice(Pathtype::Relative),
rotation_on_screen: false,
rotation: "0".into(),
unit_label: String::new(),
unit_factor: String::new(),
found_in: String::new(),
saved_path: String::new(),
details: false,
preview: None,
}
}
/// Parsed X/Y/Z scale; a uniform scale takes X for all three.
pub fn scale_values(&self) -> Option<[f64; 3]> {
let parse = |s: &String| s.trim().parse::<f64>().ok();
let x = parse(&self.scale[0])?;
if self.uniform {
return Some([x, x, x]);
}
Some([x, parse(&self.scale[1])?, parse(&self.scale[2])?])
}
pub fn insert_point(&self) -> Option<glam::DVec3> {
let parse = |s: &String| s.trim().parse::<f64>().ok();
Some(glam::DVec3::new(
parse(&self.insert[0])?,
parse(&self.insert[1])?,
parse(&self.insert[2])?,
))
}
}
fn msg(m: XrefAttachMsg) -> Message {
Message::XrefAttach(m)
}
fn number_field<'a>(
label: &'a str,
value: &'a str,
enabled: bool,
on_input: impl Fn(String) -> Message + 'a,
) -> Element<'a, Message> {
let mut field = text_input("", value)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style);
if enabled {
field = field.on_input(on_input);
}
row![
text(label).size(11).style(muted_style).width(Length::Fixed(16.0)),
field
]
.spacing(6)
.align_y(iced::Center)
.into()
}
fn read_only_field<'a>(label: String, value: &'a str, label_width: f32) -> Element<'a, Message> {
row![
text(label)
.size(11)
.style(muted_style)
.width(Length::Fixed(label_width)),
text_input("", value)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style),
]
.spacing(6)
.align_y(iced::Center)
.into()
}
pub fn view_window<'a>(
state: &'a XrefAttachState,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'a, Message> {
// ── Name ────────────────────────────────────────────────────────────
let name_combo = combo_box(
&state.name_combo,
"",
if state.name.is_empty() {
None
} else {
Some(&state.name)
},
|chosen: String| msg(XrefAttachMsg::Name(chosen)),
)
.size(11)
.padding([3, 6])
.width(Fill);
let name_section = column![
text(t!("Name:")).size(11).style(muted_style),
row![
name_combo,
button(text(t!("Browse...")).size(11))
.on_press(msg(XrefAttachMsg::Browse))
.style(button_style(false))
.padding([4, 12])
.width(Length::Fixed(96.0)),
]
.spacing(8)
.align_y(iced::Center),
]
.spacing(3);
// ── Preview + reference type ────────────────────────────────────────
let preview_body: Element<'a, Message> = match &state.preview {
Some(handle) => image(handle.clone()).width(Fill).height(Fill).into(),
None => Space::new().width(Fill).height(Fill).into(),
};
let preview = group(
t!("Preview").into_owned(),
container(preview_body)
.width(Fill)
.height(Length::Fixed(132.0))
.style(|theme: &Theme| container::Style {
background: Some(iced::Background::Color(
theme.palette().background.base.color,
)),
border: iced::Border {
width: 1.0,
radius: 2.0.into(),
color: theme.palette().background.strong.color,
},
..Default::default()
}),
Length::Shrink,
);
let reference_type = group(
t!("Reference Type").into_owned(),
column![
radio(
t!("Attachment"),
false,
Some(state.overlay),
|v| msg(XrefAttachMsg::Overlay(v))
)
.size(14)
.text_size(11),
radio(t!("Overlay"), true, Some(state.overlay), |v| msg(
XrefAttachMsg::Overlay(v)
))
.size(14)
.text_size(11),
]
.spacing(6),
Length::Shrink,
);
let left = column![preview, reference_type]
.spacing(6)
.width(Length::FillPortion(1));
// ── Scale + insertion point ─────────────────────────────────────────
let scale_enabled = !state.scale_on_screen;
let scale = group(
t!("Scale").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.scale_on_screen, |v| msg(
XrefAttachMsg::ScaleOnScreen(v)
)),
number_field("X:", &state.scale[0], scale_enabled, |v| msg(
XrefAttachMsg::Scale(0, v)
)),
number_field(
"Y:",
&state.scale[1],
scale_enabled && !state.uniform,
|v| msg(XrefAttachMsg::Scale(1, v))
),
number_field(
"Z:",
&state.scale[2],
scale_enabled && !state.uniform,
|v| msg(XrefAttachMsg::Scale(2, v))
),
labeled_checkbox(t!("Uniform Scale"), state.uniform, |v| msg(
XrefAttachMsg::Uniform(v)
)),
]
.spacing(5),
Length::Shrink,
);
let insert_enabled = !state.insert_on_screen;
let insertion = group(
t!("Insertion point").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.insert_on_screen, |v| msg(
XrefAttachMsg::InsertOnScreen(v)
)),
number_field("X:", &state.insert[0], insert_enabled, |v| msg(
XrefAttachMsg::Insert(0, v)
)),
number_field("Y:", &state.insert[1], insert_enabled, |v| msg(
XrefAttachMsg::Insert(1, v)
)),
number_field("Z:", &state.insert[2], insert_enabled, |v| msg(
XrefAttachMsg::Insert(2, v)
)),
]
.spacing(5),
Length::Shrink,
);
let middle = column![scale, insertion]
.spacing(6)
.width(Length::FillPortion(1));
// ── Path type, rotation, block unit ─────────────────────────────────
let path_type = group(
t!("Path type").into_owned(),
pick_list(Some(state.path_type), PathTypeChoice::ALL, |choice| choice.to_string())
.on_select(|v| msg(XrefAttachMsg::PathType(v)))
.text_size(11)
.padding([3, 6])
.width(Fill),
Length::Shrink,
);
let mut angle = text_input("", &state.rotation)
.size(11)
.padding([3, 6])
.width(Fill)
.style(field_style);
if !state.rotation_on_screen {
angle = angle.on_input(|v| msg(XrefAttachMsg::Rotation(v)));
}
let rotation = group(
t!("Rotation").into_owned(),
column![
labeled_checkbox(t!("Specify On-screen"), state.rotation_on_screen, |v| msg(
XrefAttachMsg::RotationOnScreen(v)
)),
row![
text(t!("Angle:"))
.size(11)
.style(muted_style)
.width(Length::Fixed(44.0)),
angle
]
.spacing(6)
.align_y(iced::Center),
]
.spacing(5),
Length::Shrink,
);
let block_unit = group(
t!("Block Unit").into_owned(),
column![
read_only_field(t!("Unit:").into_owned(), &state.unit_label, 52.0),
read_only_field(t!("Factor:").into_owned(), &state.unit_factor, 52.0),
]
.spacing(5),
Length::Shrink,
);
let right = column![path_type, rotation, block_unit]
.spacing(6)
.width(Length::FillPortion(1));
let body = row![left, middle, right].spacing(6).width(Fill);
// ── Details ─────────────────────────────────────────────────────────
let details: Element<'a, Message> = if state.details {
group(
t!("Location").into_owned(),
column![
read_only_field(t!("Found in:").into_owned(), &state.found_in, 92.0),
read_only_field(t!("Saved path:").into_owned(), &state.saved_path, 92.0),
]
.spacing(5),
Length::Shrink,
)
} else {
Space::new().height(0).into()
};
// ── Footer ──────────────────────────────────────────────────────────
let details_label = if state.details {
t!("Hide Details")
} else {
t!("Show Details")
};
let footer = row![
button(text(details_label).size(11))
.on_press(msg(XrefAttachMsg::Details(!state.details)))
.style(button_style(false))
.padding([4, 12]),
Space::new().width(Fill),
button(text(t!("OK")).size(11))
.on_press(msg(XrefAttachMsg::Apply))
.style(button_style(true))
.padding([4, 16]),
button(text(t!("Cancel")).size(11))
.on_press(Message::CloseModal)
.style(button_style(false))
.padding([4, 12]),
button(text(t!("Help")).size(11))
.on_press(msg(XrefAttachMsg::Help))
.style(button_style(false))
.padding([4, 12]),
]
.spacing(6)
.align_y(iced::Center);
column![name_section, body, details, footer]
.spacing(6)
.padding([8, 10])
.width(sizing.width)
.into()
}