Merge main into Select-Color-Redesign

Resolve conflicts with the new gradient and graphic-attributes colour
targets: keep main's targets and menu handling, routed through the new
ColorPicker state; adapt dock/view.rs to `color_picker`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_013rGmDUNr1hUf4zo1X3dak8
This commit is contained in:
Claude committed 2026-10-12 19:29:37 +00:00
commit 0904661192
220 files changed
+37313 -5826

No files matched your search

+481 -69
View File
@@ -1,5 +1,7 @@
use std::collections::{BTreeMap, BTreeSet};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};
use codec::entities::{table::CellRange, TableBuilder};
use codec::objects::{ClassObject, ClassObjectData, DataLink, DataLinkCustomData, ObjectType};
@@ -25,6 +27,135 @@ struct ExtractionRecord {
details: String,
}
// ── In-session picked-file registries (web) ───────────────────────────────
//
// The browser exposes no reusable filesystem path: a picked file's bytes exist
// only while the app keeps them. Both registries below hold those bytes (and
// what was parsed from them) keyed by the reference the drawing or wizard
// carries, following the image-model / PDF-raster pattern. Native builds never
// fill them — the real path stays readable — but the lookup and matching
// logic compiles everywhere so it stays unit-testable.
fn picked_sources() -> &'static Mutex<HashMap<String, Arc<Vec<u8>>>> {
static SOURCES: OnceLock<Mutex<HashMap<String, Arc<Vec<u8>>>>> = OnceLock::new();
SOURCES.get_or_init(|| Mutex::new(HashMap::new()))
}
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
fn extraction_documents() -> &'static Mutex<HashMap<String, Arc<CadDocument>>> {
static DOCUMENTS: OnceLock<Mutex<HashMap<String, Arc<CadDocument>>>> = OnceLock::new();
DOCUMENTS.get_or_init(|| Mutex::new(HashMap::new()))
}
/// Lower-cased trailing component of a path-style reference. Links and wizard
/// sources carry whatever the author stored, so the file name — not the full
/// path — is the only component a re-picked file can be expected to match.
fn source_name_key(path: &str) -> String {
path.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_ascii_lowercase()
}
/// Do two source references name the same file? Compares trailing components
/// case-insensitively (Windows paths are case-insensitive where the link was
/// authored, and the web has no path beyond the name at all).
fn same_source_name(a: &str, b: &str) -> bool {
let (a, b) = (source_name_key(a), source_name_key(b));
!a.is_empty() && a == b
}
/// Keep the bytes of a spreadsheet or settings file chosen through a picker.
/// Re-opening the link in-session re-reads these instead of demanding a new
/// pick; re-picking the same file overwrites the entry.
// Only the web registers sources (native re-reads the real file, so a file
// edited after picking is picked up); tests exercise the registry directly.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
/// Returns false when the registration REPLACED different bytes under the
/// same key — the browser hands back bare file names, so two distinct files
/// named alike collide and every reference to that name would silently read
/// the newer pick. Callers surface that to the user.
pub(crate) fn register_picked_source(path: &str, bytes: Arc<Vec<u8>>) -> bool {
let mut cache = picked_sources()
.lock()
.unwrap_or_else(|error| error.into_inner());
let Some(old) = cache.get(path) else {
cache.insert(path.to_string(), bytes);
return true;
};
if old.as_slice() == bytes.as_slice() {
return true;
}
// Refuse AND keep the old bytes: overwriting would silently retarget
// every reference reading this name. The name is remembered so the
// browse handlers can tell the user why their pick did nothing.
take_last_collision().replace(file_name_of(path));
false
}
/// The name whose re-pick was refused because different bytes are already
/// registered under it — the browse handler reports and clears it.
fn last_collision() -> &'static Mutex<Option<String>> {
static LAST: OnceLock<Mutex<Option<String>>> = OnceLock::new();
LAST.get_or_init(|| Mutex::new(None))
}
fn take_last_collision() -> std::sync::MutexGuard<'static, Option<String>> {
last_collision()
.lock()
.unwrap_or_else(|error| error.into_inner())
}
fn file_name_of(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
/// Bytes registered for this reference — exact key first, then any entry that
/// names the same file (the web fallback for links whose stored path is the
/// original author's, while the user picked only the bare file).
pub(crate) fn picked_source_bytes(path: &str) -> Option<Arc<Vec<u8>>> {
let cache = picked_sources()
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(bytes) = cache.get(path) {
return Some(Arc::clone(bytes));
}
cache
.iter()
.find(|(key, _)| same_source_name(key, path))
.map(|(_, bytes)| Arc::clone(bytes))
}
/// Keep a source drawing parsed at pick time, keyed by the name the wizard
/// lists. The extraction pipeline reads these on the web instead of calling
/// the path-based `load_file`.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn register_extraction_document(name: &str, document: CadDocument) {
extraction_documents()
.lock()
.unwrap_or_else(|error| error.into_inner())
.insert(name.to_string(), Arc::new(document));
}
/// The parsed drawing registered under this source reference, matched by exact
/// key or by file name.
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
pub(crate) fn extraction_document(path: &str) -> Option<Arc<CadDocument>> {
let cache = extraction_documents()
.lock()
.unwrap_or_else(|error| error.into_inner());
if let Some(document) = cache.get(path) {
return Some(Arc::clone(document));
}
cache
.iter()
.find(|(key, _)| same_source_name(key, path))
.map(|(_, document)| Arc::clone(document))
}
impl ExtractionRecord {
fn value(&self, key: &str) -> String {
match key {
@@ -88,11 +219,15 @@ fn data_link_choices(doc: &CadDocument) -> Vec<DataLinkChoice> {
ObjectType::ClassObject(object) => match &object.data {
ClassObjectData::DataLink(link) => {
let path = resolve_link_path(doc, link);
let path_text = path.to_string_lossy().into_owned();
// The web has no filesystem: a link is valid when its
// picked bytes are still registered in-session.
let valid = path.is_file() || picked_source_bytes(&path_text).is_some();
Some(DataLinkChoice {
handle: *handle,
name: link_display_name(link),
path: path.to_string_lossy().into_owned(),
valid: path.is_file(),
path: path_text,
valid,
})
}
_ => None,
@@ -180,6 +315,12 @@ fn workbook_metadata(path: &Path) -> Result<(Vec<String>, Vec<String>), String>
if ext == "csv" || ext == "txt" {
return Ok((Vec::new(), Vec::new()));
}
if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
return workbook_metadata_bytes(&bytes);
}
if cfg!(target_arch = "wasm32") {
return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned());
}
use calamine::{open_workbook_auto, Reader};
let workbook = open_workbook_auto(path).map_err(|error| error.to_string())?;
let sheets = workbook.sheet_names();
@@ -191,6 +332,20 @@ fn workbook_metadata(path: &Path) -> Result<(Vec<String>, Vec<String>), String>
Ok((sheets, names))
}
/// Sheet and defined-name catalog read straight from bytes (a picked file on
/// the web, where no path exists to reopen).
fn workbook_metadata_bytes(bytes: &[u8]) -> Result<(Vec<String>, Vec<String>), String> {
use calamine::{open_workbook_auto_from_rs, Reader};
let workbook = open_workbook_auto_from_rs(Cursor::new(bytes)).map_err(|error| error.to_string())?;
let sheets = workbook.sheet_names();
let names = workbook
.defined_names()
.iter()
.map(|(name, _)| name.clone())
.collect();
Ok((sheets, names))
}
fn named_range_target(formula: &str) -> Option<(String, String)> {
let formula = formula.trim().trim_start_matches('=');
let (sheet, range) = formula.rsplit_once('!')?;
@@ -199,6 +354,66 @@ fn named_range_target(formula: &str) -> Option<(String, String)> {
(!sheet.is_empty() && !range.is_empty()).then_some((sheet, range))
}
/// Read the text of a CSV/TXT (or settings) source: registered picked bytes
/// first, then the native filesystem. On the web an unregistered path has
/// nothing behind it, so it asks for the pick instead of failing on I/O.
fn read_text_source(path: &Path) -> Result<String, String> {
if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
return Ok(String::from_utf8_lossy(&bytes).into_owned());
}
if cfg!(target_arch = "wasm32") {
return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned());
}
std::fs::read_to_string(path).map_err(|error| error.to_string())
}
/// Sheet selection + range resolution shared by the path- and bytes-based
/// workbook readers. Returns the raw rows and the cell range to crop to, if
/// any.
fn workbook_rows<RS: std::io::Read + std::io::Seek>(
workbook: &mut calamine::Sheets<RS>,
sheet: Option<&str>,
range_kind: LinkRange,
range_value: Option<&str>,
) -> Result<(Vec<Vec<String>>, Option<String>), String> {
use calamine::Reader;
let (selected_sheet, cell_range) = if range_kind == LinkRange::NamedRange {
let name = range_value
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| crate::t!("Select a named range.").into_owned())?;
let formula = workbook
.defined_names()
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
.map(|(_, formula)| formula.clone())
.ok_or_else(|| crate::t!("The named range was not found.").into_owned())?;
named_range_target(&formula)
.ok_or_else(|| crate::t!("The named range does not refer to a worksheet cell range.").into_owned())?
} else {
let selected_sheet = sheet
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.or_else(|| workbook.sheet_names().first().cloned())
.ok_or_else(|| crate::t!("The spreadsheet contains no worksheets.").into_owned())?;
let range = (range_kind == LinkRange::CellRange)
.then_some(range_value)
.flatten()
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_default();
(selected_sheet, range)
};
let data = workbook
.worksheet_range(&selected_sheet)
.map_err(|error| error.to_string())?;
Ok((
data.rows()
.map(|row| row.iter().map(ToString::to_string).collect())
.collect(),
(!cell_range.is_empty()).then_some(cell_range),
))
}
pub(crate) fn read_tabular_file(
path: &Path,
sheet: Option<&str>,
@@ -214,53 +429,24 @@ pub(crate) fn read_tabular_file(
if range_kind == LinkRange::NamedRange {
return Err(crate::t!("Named ranges require a spreadsheet file.").into_owned());
}
let text = std::fs::read_to_string(path).map_err(|error| error.to_string())?;
(
crate::app::commands::display::parse_csv_table(&text),
crate::app::commands::display::parse_csv_table(&read_text_source(path)?),
(range_kind == LinkRange::CellRange)
.then_some(range_value)
.flatten()
.map(str::to_string),
)
} else {
use calamine::{open_workbook_auto, Reader};
let mut workbook = open_workbook_auto(path).map_err(|error| error.to_string())?;
let (selected_sheet, cell_range) = if range_kind == LinkRange::NamedRange {
let name = range_value
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| crate::t!("Select a named range.").into_owned())?;
let formula = workbook
.defined_names()
.iter()
.find(|(candidate, _)| candidate.eq_ignore_ascii_case(name))
.map(|(_, formula)| formula.clone())
.ok_or_else(|| crate::t!("The named range was not found.").into_owned())?;
named_range_target(&formula).ok_or_else(|| {
crate::t!("The named range does not refer to a worksheet cell range.").into_owned()
})?
} else {
let selected_sheet = sheet
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.or_else(|| workbook.sheet_names().first().cloned())
.ok_or_else(|| crate::t!("The spreadsheet contains no worksheets.").into_owned())?;
let range = (range_kind == LinkRange::CellRange)
.then_some(range_value)
.flatten()
.filter(|value| !value.trim().is_empty())
.map(str::to_string)
.unwrap_or_default();
(selected_sheet, range)
};
let data = workbook
.worksheet_range(&selected_sheet)
} else if let Some(bytes) = picked_source_bytes(&path.to_string_lossy()) {
use calamine::open_workbook_auto_from_rs;
let mut workbook = open_workbook_auto_from_rs(Cursor::new(bytes.as_slice()))
.map_err(|error| error.to_string())?;
(
data.rows()
.map(|row| row.iter().map(ToString::to_string).collect())
.collect(),
(!cell_range.is_empty()).then_some(cell_range),
)
workbook_rows(&mut workbook, sheet, range_kind, range_value)?
} else if cfg!(target_arch = "wasm32") {
return Err(crate::t!("Pick the file again to load its contents in the browser.").into_owned());
} else {
use calamine::open_workbook_auto;
let mut workbook = open_workbook_auto(path).map_err(|error| error.to_string())?;
workbook_rows(&mut workbook, sheet, range_kind, range_value)?
};
let rows = match cell_range
.as_deref()
@@ -471,12 +657,14 @@ fn collect_records(
}
}
#[cfg(not(target_arch = "wasm32"))]
fn is_drawing_file(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.is_some_and(|value| value.eq_ignore_ascii_case("dwg") || value.eq_ignore_ascii_case("dxf"))
}
#[cfg(not(target_arch = "wasm32"))]
fn collect_drawing_paths(
path: &Path,
recursive: bool,
@@ -505,6 +693,7 @@ fn collect_drawing_paths(
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn extraction_drawing_paths(state: &DataExtractionState) -> Result<Vec<PathBuf>, String> {
let mut paths = Vec::new();
for source in &state.source_files {
@@ -515,6 +704,31 @@ fn extraction_drawing_paths(state: &DataExtractionState) -> Result<Vec<PathBuf>,
Ok(paths)
}
/// Visit the source drawings the extraction pipeline reads beyond the current
/// one. Native loads them from disk one at a time (folders walked
/// recursively), so only one foreign drawing is in memory at once; the web
/// reads the documents parsed at pick time from the in-session registry —
/// every listed source must have been picked, or it asks for the pick again.
fn for_each_extraction_document(
state: &DataExtractionState,
mut visit: impl FnMut(&CadDocument),
) -> Result<(), String> {
#[cfg(target_arch = "wasm32")]
for source in &state.source_files {
let document = extraction_document(source).ok_or_else(|| {
crate::tf!("Drawing not loaded in the browser: {name}.", name = source).into_owned()
})?;
visit(&document);
}
#[cfg(not(target_arch = "wasm32"))]
for source in extraction_drawing_paths(state)? {
let document = crate::io::load_file(&source)
.map_err(|error| format!("{}: {error}", source.display()))?;
visit(&document);
}
Ok(())
}
fn collect_type_counts(
doc: &CadDocument,
allowed_handles: Option<&BTreeSet<Handle>>,
@@ -560,11 +774,9 @@ fn extraction_preview(
if state.include_current {
collect_records(current, None, &allowed_types, &mut records);
}
for source in extraction_drawing_paths(state)? {
let doc = crate::io::load_file(&source)
.map_err(|error| format!("{}: {error}", source.display()))?;
collect_records(&doc, None, &allowed_types, &mut records);
}
for_each_extraction_document(state, |document| {
collect_records(document, None, &allowed_types, &mut records);
})?;
}
}
let props = state
@@ -693,11 +905,25 @@ fn save_extraction_settings(state: &DataExtractionState) -> Result<(), String> {
"tableTitle": state.table_title,
});
let text = serde_json::to_string_pretty(&value).map_err(|error| error.to_string())?;
std::fs::write(&state.settings_path, text).map_err(|error| error.to_string())
#[cfg(target_arch = "wasm32")]
{
// No filesystem to write to: hand the settings to the browser as a
// download, mirroring the native write of the same JSON.
let name = Path::new(state.settings_path.trim())
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "extraction.dxex".into());
crate::sys::download_bytes(&name, text.as_bytes());
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
{
std::fs::write(&state.settings_path, text).map_err(|error| error.to_string())
}
}
fn load_extraction_settings(state: &mut DataExtractionState) -> Result<(), String> {
let text = std::fs::read_to_string(&state.settings_path).map_err(|error| error.to_string())?;
let text = read_text_source(Path::new(state.settings_path.trim()))?;
let value: serde_json::Value =
serde_json::from_str(&text).map_err(|error| error.to_string())?;
state.source = match value["source"].as_str() {
@@ -1072,10 +1298,38 @@ impl OpenCADStudio {
Task::none()
}
/// Web DATALINK: the picked .csv's bytes arrived. They are registered in
/// the picked-source registry — so `DATALINKUPDATE` re-reads them
/// in-session instead of failing on the absent filesystem — and fed to
/// the same table-insert path the native typed-path command uses.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_datalink_csv_picked(
&mut self,
picked: Option<(String, Vec<u8>)>,
) -> Task<Message> {
let Some((name, bytes)) = picked else {
return Task::none();
};
let text = String::from_utf8_lossy(&bytes).into_owned();
if !register_picked_source(&name, Arc::new(bytes)) {
if let Some(name) = take_last_collision().take() {
self.command_line.push_warning(
crate::tf!(
"\"{name}\" is already loaded with different contents; the existing file was kept."
)
.as_ref(),
);
}
return Task::none();
}
self.place_data_link_table(&text, &name);
Task::none()
}
pub(super) fn on_data_link_browse(&mut self) -> Task<Message> {
Task::perform(
async {
crate::sys::file_dialog()
let Some(handle) = crate::sys::file_dialog()
.set_title(crate::t!("Choose a Spreadsheet File").as_ref())
.add_filter(
crate::t!("Spreadsheet Files").as_ref(),
@@ -1084,13 +1338,39 @@ impl OpenCADStudio {
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_file()
.await
.map(|handle| crate::sys::handle_path(&handle))
else {
return None;
};
let path = crate::sys::handle_path(&handle);
// The browser forgets the file the moment the pick resolves:
// keep its bytes so preview, save, and later re-opens read
// them instead of asking for the picker again.
#[cfg(target_arch = "wasm32")]
{
let fresh = register_picked_source(
&path.to_string_lossy(),
Arc::new(handle.read().await),
);
if !fresh {
return None;
}
}
Some(path)
},
Message::DataLinkBrowseResult,
)
}
pub(super) fn on_data_link_browse_result(&mut self, path: Option<PathBuf>) -> Task<Message> {
#[cfg(target_arch = "wasm32")]
if let Some(name) = take_last_collision().take() {
self.command_line.push_warning(
crate::tf!(
"\"{name}\" is already loaded with different contents; the existing file was kept."
)
.as_ref(),
);
}
if let Some(path) = path {
self.data_link_manager.path = path.to_string_lossy().into_owned();
let (sheets, named_ranges) = workbook_metadata(&path).unwrap_or_default();
@@ -1403,11 +1683,9 @@ impl OpenCADStudio {
if state.include_current {
collect_type_counts(current, None, &mut counts);
}
for path in extraction_drawing_paths(&state)? {
let doc = crate::io::load_file(&path)
.map_err(|error| format!("{}: {error}", path.display()))?;
collect_type_counts(&doc, None, &mut counts);
}
for_each_extraction_document(&state, |document| {
collect_type_counts(document, None, &mut counts);
})?;
}
}
let previous = self
@@ -1570,18 +1848,27 @@ impl OpenCADStudio {
.set_title(crate::t!("Data Extraction Settings").as_ref())
.add_filter(crate::t!("Data Extraction Settings").as_ref(), &["dxex"])
.add_filter(crate::t!("All Files").as_ref(), &["*"]);
if save {
dialog
.set_file_name("extraction.dxex")
.save_file()
.await
.map(|handle| crate::sys::handle_path(&handle))
let handle = if save {
dialog.set_file_name("extraction.dxex").save_file().await
} else {
dialog
.pick_file()
.await
.map(|handle| crate::sys::handle_path(&handle))
dialog.pick_file().await
};
let handle = handle?;
let path = crate::sys::handle_path(&handle);
// Loading settings in the browser: the picked .dxex has no
// path to read back from, so its bytes are kept for the load
// that follows this message.
#[cfg(target_arch = "wasm32")]
if !save {
let fresh = register_picked_source(
&path.to_string_lossy(),
Arc::new(handle.read().await),
);
if !fresh {
return None;
}
}
Some(path)
},
Message::DataExtractionBrowseSettingsResult,
)
@@ -1591,6 +1878,13 @@ impl OpenCADStudio {
&mut self,
path: Option<PathBuf>,
) -> Task<Message> {
#[cfg(target_arch = "wasm32")]
if let Some(name) = take_last_collision().take() {
self.data_extraction.error = crate::tf!(
"\"{name}\" is already loaded with different contents; the existing file was kept."
)
.into_owned();
}
if let Some(path) = path {
self.data_extraction.settings_path = path.to_string_lossy().into_owned();
if self.data_extraction.begin != ExtractionBegin::New {
@@ -1604,10 +1898,11 @@ impl OpenCADStudio {
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task<Message> {
Task::perform(
async {
crate::sys::file_dialog()
Ok(crate::sys::file_dialog()
.set_title(crate::t!("Add Drawings").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
@@ -1619,7 +1914,52 @@ impl OpenCADStudio {
.unwrap_or_default()
.into_iter()
.map(|handle| crate::sys::handle_path(&handle))
.collect()
.collect())
},
Message::DataExtractionAddDrawingsResult,
)
}
/// Web: the picked drawings have no paths to re-open later, so each one is
/// parsed on the shared worker right here and the document registered
/// under its name — the pipeline then reads the registry instead of the
/// filesystem. The first file that fails to parse aborts the add with the
/// same `name: error` shape the native refresh reports.
#[cfg(target_arch = "wasm32")]
pub(super) fn on_data_extraction_add_drawings(&mut self) -> Task<Message> {
Task::perform(
async {
let handles = crate::sys::file_dialog()
.set_title(crate::t!("Add Drawings").as_ref())
.add_filter(
crate::t!("CAD Files").as_ref(),
&["dwg", "dxf", "DWG", "DXF"],
)
.add_filter(crate::t!("All Files").as_ref(), &["*"])
.pick_files()
.await
.unwrap_or_default();
// Each picked file becomes a full parsed document held in
// the registry for the session; a bound keeps a giant
// multi-pick from exhausting the wasm heap (tab death).
const MAX_EXTRACTION_SOURCES: usize = 16;
if handles.len() > MAX_EXTRACTION_SOURCES {
return Err(crate::tf!(
"At most {MAX_EXTRACTION_SOURCES} drawings can be added in the browser."
)
.into_owned());
}
let mut paths = Vec::new();
for handle in handles {
let name = handle.file_name();
let bytes = handle.read().await;
let document = crate::io::parse_drawing_bytes_web(&name, &bytes)
.await
.map_err(|error| format!("{name}: {error}"))?;
register_extraction_document(&name, document);
paths.push(PathBuf::from(name));
}
Ok(paths)
},
Message::DataExtractionAddDrawingsResult,
)
@@ -1740,6 +2080,18 @@ impl OpenCADStudio {
} else {
rows_to_csv(&rows)
};
// Web output mirrors the native CSV/TXT write (there is no xlsx
// writer on either target): the browser gets the same bytes as a
// download under the picked name.
#[cfg(target_arch = "wasm32")]
{
let name = path
.file_name()
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|| "extraction.csv".into());
crate::sys::download_bytes(&name, text.as_bytes());
}
#[cfg(not(target_arch = "wasm32"))]
if let Err(error) = std::fs::write(&path, text) {
self.data_extraction.error = error.to_string();
return Task::none();
@@ -1771,6 +2123,23 @@ impl OpenCADStudio {
#[cfg(test)]
mod tests {
/// A refused same-name re-pick must KEEP the old bytes: overwriting
/// would silently retarget every reference reading that name.
#[test]
fn collision_refusal_keeps_the_registered_bytes() {
register_picked_source("a.csv", Arc::new(b"old".to_vec()));
let fresh = register_picked_source("a.csv", Arc::new(b"new".to_vec()));
assert!(!fresh, "differing bytes under a live name are refused");
assert_eq!(
picked_source_bytes("a.csv").as_ref().map(|b| b.as_slice()),
Some(&b"old"[..]),
"the old registration survives the refusal"
);
// Identical bytes are a no-op accept.
assert!(register_picked_source("a.csv", Arc::new(b"old".to_vec())));
}
use super::*;
#[test]
@@ -1835,4 +2204,47 @@ mod tests {
vec![vec!["x".to_string()]]
);
}
#[test]
fn csv_links_read_from_registered_bytes() {
let name = format!("ocs-registry-{}-{}.csv", std::process::id(), line!());
register_picked_source(&name, Arc::new(b"a,b,c\nd,e,f\n".to_vec()));
let rows = read_tabular_file(Path::new(&name), None, LinkRange::CellRange, Some("B1:C2"));
assert_eq!(
rows.unwrap(),
vec![
vec!["b".to_string(), "c".to_string()],
vec!["e".to_string(), "f".to_string()],
]
);
}
#[test]
fn registered_bytes_match_by_file_name() {
let key = format!("ocs-match-{}-{}.xlsx", std::process::id(), line!());
register_picked_source(
&format!(r"C:\drawings\{key}"),
Arc::new(Vec::new()),
);
// Exact key…
assert!(picked_source_bytes(&format!(r"C:\drawings\{key}")).is_some());
// …same file under a differently-cased bare name (the web stores only
// the name, links may store the author's path)…
assert!(picked_source_bytes(&key.to_ascii_uppercase()).is_some());
// …and not another file.
assert!(picked_source_bytes("ocs-match-other.xlsx").is_none());
}
#[test]
fn registered_extraction_documents_match_by_file_name() {
let key = format!("ocs-doc-{}-{}.dwg", std::process::id(), line!());
register_extraction_document(&key, CadDocument::new());
assert!(extraction_document(&key.to_ascii_uppercase()).is_some());
assert!(extraction_document("ocs-doc-other.dwg").is_none());
}
#[test]
fn workbook_metadata_bytes_rejects_non_workbooks() {
assert!(workbook_metadata_bytes(b"not a workbook").is_err());
}
}
+341
View File
@@ -284,6 +284,27 @@ pub(crate) fn entity_json(e: &codec::EntityType, detail: &str) -> Value {
map.insert("start_angle".into(), json!(a.start_angle));
map.insert("end_angle".into(), json!(a.end_angle));
}
E::Spline(spline) => {
map.insert("degree".into(), json!(spline.degree));
map.insert("knots".into(), json!(spline.knots));
map.insert(
"control_points".into(),
json!(spline
.control_points
.iter()
.map(|point| v3(*point))
.collect::<Vec<_>>()),
);
map.insert(
"fit_points".into(),
json!(spline
.fit_points
.iter()
.map(|point| v3(*point))
.collect::<Vec<_>>()),
);
map.insert("weights".into(), json!(spline.weights));
}
E::Point(p) => {
map.insert("location".into(), v3(p.location));
}
@@ -452,11 +473,34 @@ pub(super) fn requested_save_target(
Ok((version, is_dxf))
}
fn is_generated_dimension_block_name(name: &str) -> bool {
let Some(suffix) = name
.strip_prefix("*D")
.or_else(|| name.strip_prefix("*d"))
else {
return false;
};
!suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_digit())
}
/// Save-time baking materializes a Dimension's graphics in an anonymous
/// `*D<n>` block. Those members are derived display geometry, not additional
/// logical drawing entities; named block members remain part of the manifest.
fn is_dimension_display_entity(document: &codec::CadDocument, entity: &codec::EntityType) -> bool {
let owner = entity.common().owner_handle;
document.block_records.iter().any(|block| {
block.handle == owner && is_generated_dimension_block_name(&block.name)
})
}
fn document_manifest(document: &codec::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() {
if is_dimension_display_entity(document, entity) {
continue;
}
*by_type
.entry(crate::entities::names::ui_name(entity).to_string())
.or_default() += 1;
@@ -1547,6 +1591,16 @@ impl OpenCADStudio {
"ok":false,"status":"failed","code":"save_failed","error":error,
})
})?;
// The bytes are now ours even if a later hash/audit/reopen check fails.
// Refresh the guard immediately so a diagnostic failure cannot make the
// next ordinary save look like an external edit of the same path.
if self.tabs[i]
.current_path
.as_deref()
.is_some_and(|current| super::update::native_paths_match(current, &path))
{
self.refresh_native_edit_guard_after_save(i, &path, false, None);
}
let sha256 = sha256_file(&path).map_err(|error| {
json!({
"ok":false,"status":"failed","code":"hash_failed","error":error,
@@ -1723,6 +1777,134 @@ mod tests {
let _ = std::fs::remove_file(path);
}
#[test]
fn manifest_ignores_dimension_display_members_but_keeps_named_block_members() {
use codec::entities::{Dimension, DimensionLinear, Line};
use codec::tables::BlockRecord;
use codec::types::Vector3;
use codec::{CadDocument, EntityType};
let mut document = CadDocument::new();
let mut detail = BlockRecord::new("DETAIL");
detail.handle = document.allocate_handle();
let detail_handle = detail.handle;
document.block_records.add(detail).unwrap();
let mut detail_line = Line::from_points(
Vector3::new(20.0, 0.0, 0.0),
Vector3::new(30.0, 0.0, 0.0),
);
detail_line.common.owner_handle = detail_handle;
document.add_entity(EntityType::Line(detail_line)).unwrap();
let mut dimension = DimensionLinear::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
);
dimension.definition_point = Vector3::new(0.0, 5.0, 0.0);
dimension.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0);
document
.add_entity(EntityType::Dimension(Dimension::Linear(dimension)))
.unwrap();
crate::modules::draw::modify::explode::bake_dimension_blocks(&mut document);
let manifest = super::document_manifest(&document);
assert_eq!(manifest["total"], 2, "{manifest}");
assert_eq!(manifest["by_type"]["Dimension"], 1, "{manifest}");
assert_eq!(manifest["by_type"]["Line"], 1, "{manifest}");
}
#[test]
fn save_verified_dimension_round_trip_ignores_generated_display_block() {
use codec::entities::{Dimension, DimensionLinear, EntityType};
use codec::types::Vector3;
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let mut dimension = DimensionLinear::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
);
dimension.definition_point = Vector3::new(0.0, 5.0, 0.0);
dimension.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0);
app.tabs[app.active_tab]
.scene
.add_entity(EntityType::Dimension(Dimension::Linear(dimension)));
let path = std::env::temp_dir().join(format!(
"ocs_verified_dimension_{}_{}.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("dimension save_verified");
assert_eq!(result["verified"], true, "{result}");
assert_eq!(result["manifest"]["total"], 1, "{result}");
assert_eq!(result["manifest"]["by_type"]["Dimension"], 1, "{result}");
let _ = std::fs::remove_file(path);
}
#[test]
fn save_verified_refreshes_current_disk_fingerprint_for_follow_up_save() {
use codec::entities::{Dimension, DimensionLinear, EntityType};
use codec::types::Vector3;
let mut app = super::OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let mut dimension = DimensionLinear::new(
Vector3::new(0.0, 0.0, 0.0),
Vector3::new(10.0, 0.0, 0.0),
);
dimension.definition_point = Vector3::new(0.0, 5.0, 0.0);
dimension.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0);
let i = app.active_tab;
app.tabs[i]
.scene
.add_entity(EntityType::Dimension(Dimension::Linear(dimension)));
let path = std::env::temp_dir().join(format!(
"ocs_verified_dimension_follow_up_{}_{}.dwg",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let _ = std::fs::remove_file(&path);
let version = app.tabs[i].scene.document.version;
app.save_tab_synchronously_protected_as(i, path.clone(), version, true)
.expect("initial protected save");
let result = app
.save_verified_request(&serde_json::json!({
"path":path,
"target_format":"dwg",
"target_version":"R14",
"overwrite":true,
}))
.expect("dimension save_verified");
assert_eq!(result["verified"], true, "{result}");
let captured = crate::io::edit_lock::FileFingerprint::capture(&path).unwrap();
assert_eq!(app.tabs[i].disk_fingerprint.as_ref(), Some(&captured));
let ordinary = app.automation_op(
&serde_json::json!({"op":"save", "path":path}).to_string(),
);
assert_eq!(ordinary["ok"], true, "{ordinary}");
assert!(!ordinary["error"]
.as_str()
.unwrap_or_default()
.contains("changed outside"));
let _ = std::fs::remove_file(path);
}
#[test]
fn save_verified_accepts_new_dimension_with_baked_block() {
use codec::entities::{Dimension, DimensionLinear};
@@ -2392,6 +2574,90 @@ mod tests {
assert_eq!(r["code"], "invalid_text");
}
#[test]
fn entities_create_rejects_unknown_fields_and_degenerate_arcs() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let old_field = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"d8-old","document_id":{doc},"entities":[{"type":"Arc","center":[0,0],"radius":5,"start_angle":0,"end_angle":90}]}"#,
);
assert_eq!(old_field["ok"], false, "{old_field}");
assert_eq!(old_field["code"], "unknown_entity_field", "{old_field}");
let missing_angle = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"d8-missing","document_id":{doc},"entities":[{"type":"Arc","center":[0,0],"radius":5,"end_angle_deg":90}]}"#,
);
assert_eq!(missing_angle["code"], "invalid_angle", "{missing_angle}");
let zero_sweep = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"d8-zero","document_id":{doc},"entities":[{"type":"Arc","center":[0,0],"radius":5,"start_angle_deg":45,"end_angle_deg":45}]}"#,
);
assert_eq!(zero_sweep["code"], "invalid_sweep", "{zero_sweep}");
let wrapped = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"d8-wrap","document_id":{doc},"entities":[{"type":"Arc","center":[0,0],"radius":5,"start_angle_deg":270,"end_angle_deg":90}]}"#,
);
assert_eq!(wrapped["ok"], true, "{wrapped}");
}
#[test]
fn entities_create_validates_solid_corners_and_mtext_styles() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let malformed = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"d8-solid-bad","document_id":{doc},"entities":[{"type":"Solid","corners":[[1,2,3],[11,2,3],[11,12,3],[1,12]]}]}"#,
);
assert_eq!(malformed["code"], "invalid_corners", "{malformed}");
let triangle = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"d8-solid-good","document_id":{doc},"entities":[{"type":"Solid","corners":[[1,2,3],[11,2,3],[11,12,3]]}]}"#,
);
assert_eq!(triangle["ok"], true, "{triangle}");
let solid = app.tabs[app.active_tab]
.scene
.document
.entities()
.find_map(|entity| match entity {
codec::EntityType::Solid(solid) => Some(solid),
_ => None,
})
.expect("triangle solid");
assert!(solid.is_triangle());
assert_eq!(solid.third_corner, solid.fourth_corner);
assert_eq!(solid.fourth_corner.z, 3.0);
let bad_style = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"d8-style-bad","document_id":{doc},"entities":[{"type":"MText","value":"x","position":[0,0],"style":"NO_SUCH_STYLE"}]}"#,
);
assert_eq!(bad_style["code"], "invalid_text_style", "{bad_style}");
}
#[test]
fn entities_create_mtext_accepts_existing_style() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
{
let doc = &mut app.tabs[app.active_tab].scene.document;
let mut style = codec::tables::TextStyle::new("OCSM_GB");
style.handle = doc.allocate_handle();
doc.text_styles.add(style).unwrap();
}
let created = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"d8-style-ok","document_id":{doc},"entities":[{"type":"MText","value":"x","position":[0,0],"style":"OCSM_GB"}]}"#,
);
assert_eq!(created["ok"], true, "{created}");
}
#[test]
fn entities_delete_erases_by_handle_and_validates() {
let mut app = OpenCADStudio::new_for_test();
@@ -3532,6 +3798,81 @@ mod tests {
);
}
#[test]
fn test_snap_override_typed_in_line_command() {
use crate::app::Message;
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
{
app.tabs[0].scene.selection.borrow_mut().view.vp_size = (1920.0, 1080.0);
app.tabs[0].scene.sync_tiles_from_panes(1920.0, 1080.0);
}
// Start LINE
let _ = app.update(Message::CommandInput("LINE".to_string()));
let _ = app.update(Message::CommandSubmit);
assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("LINE")
);
// Turn running osnap off to prove override temporarily enables snapping
app.snapper.snap_enabled = false;
assert!(!app.snapper.snap_enabled);
// Type MID at first point prompt
let _ = app.update(Message::CommandInput("MID".to_string()));
let _ = app.update(Message::CommandSubmit);
// LINE remains the active command
assert_eq!(
app.tabs[0].active_cmd.as_ref().map(|c| c.name()),
Some("LINE")
);
// Snapper is now forced on with only Midpoint enabled
assert!(app.snapper.snap_enabled);
assert_eq!(app.snapper.enabled.len(), 1);
assert!(app.snapper.enabled.contains(&crate::snap::SnapType::Midpoint));
// Submit first point (0,0)
let _ = app.update(Message::CommandInput("0,0".to_string()));
let _ = app.update(Message::CommandSubmit);
// Override is consumed by the point commit, restoring snap_enabled == false
assert!(!app.snapper.snap_enabled);
// At next point prompt, type END
let _ = app.update(Message::CommandInput("END".to_string()));
let _ = app.update(Message::CommandSubmit);
assert!(app.snapper.snap_enabled);
assert!(app.snapper.enabled.contains(&crate::snap::SnapType::Endpoint));
// Escape cancels LINE and clears unconsumed snap override
let _ = app.update(Message::CommandEscape);
assert!(app.tabs[0].active_cmd.is_none());
assert!(!app.snapper.snap_enabled);
}
#[test]
fn test_snap_override_feed_token() {
use crate::app::Message;
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let _ = app.update(Message::CommandInput("LINE".to_string()));
let _ = app.update(Message::CommandSubmit);
app.snapper.snap_enabled = false;
let _ = app.feed_active_cmd("CEN");
assert!(app.snapper.snap_enabled);
assert_eq!(app.snapper.enabled.len(), 1);
assert!(app.snapper.enabled.contains(&crate::snap::SnapType::Center));
let _ = app.feed_active_cmd("5,5");
assert!(!app.snapper.snap_enabled);
let _ = app.update(Message::CommandEscape);
}
#[test]
fn test_open_color_dropdown() {
use crate::app::Message;
+96
View File
@@ -512,9 +512,11 @@ pub(crate) fn paste_entities_kernel(
.iter()
.cloned()
.map(|mut entity| {
let picture_placement = crate::scene::render_graph::dim_picture_placement(&entity);
if let Some(t) = translate {
crate::scene::view::dispatch::apply_transform(&mut entity, t);
}
crate::scene::render_graph::clear_dim_picture_placement(&mut entity);
// Same `*D`-block re-point as `finalize_paste` (#290, #161): the
// baked dimension geometry lives in WCS, so the paste gets its own
// transformed copy of the snapshotted block.
@@ -527,6 +529,12 @@ pub(crate) fn paste_entities_kernel(
.find(|b| b.name.eq_ignore_ascii_case(&bn))
.map(|def| def.entities.clone())
{
let mut subs = subs;
if let Some(placement) = &picture_placement {
for sub in &mut subs {
crate::scene::view::dispatch::apply_transform(sub, placement);
}
}
let bt = translate.cloned().unwrap_or(
crate::command::EntityTransform::Translate(glam::DVec3::ZERO),
);
@@ -699,6 +707,11 @@ fn add_entity_clone_doc_inner(
}
entity.common_mut().handle = Handle::NULL;
entity.common_mut().owner_handle = owner;
// An extension dictionary is exclusively owned; the clone must not claim
// the source's (cross-drawing it would dangle into the source document),
// or file validation reports "Extension dictionary <H> Cannot access".
// Paste reattaches a recreated subtree afterwards (finalize_paste).
entity.common_mut().xdictionary_handle = None;
}
/// Document-level fresh `*D<n>` block from `subs`: the `&mut CadDocument`
@@ -1461,3 +1474,86 @@ mod headless_reuse_proof_tests {
);
}
}
#[cfg(test)]
mod define_transformed_block_doc_xdict_tests {
use super::define_transformed_block_doc;
/// No extension dictionary may be referenced by more than one entity.
fn assert_no_shared_xdicts(doc: &codec::CadDocument) {
use std::collections::HashMap;
let mut owners: HashMap<codec::Handle, usize> = HashMap::new();
for entity in doc.entities() {
if let Some(xd) = entity.common().xdictionary_handle {
if !xd.is_null() {
*owners.entry(xd).or_default() += 1;
}
}
}
let shared: Vec<_> = owners
.iter()
.filter(|(_, &n)| n > 1)
.map(|(h, n)| format!("{h:?} x{n}"))
.collect();
assert!(
shared.is_empty(),
"shared extension dictionaries: {}",
shared.join(", ")
);
}
/// Pasted/derived sub-entities must not share the source's extension
/// dictionary. `define_transformed_block_doc` is the verbatim
/// document-level port of `Scene::define_transformed_block`, and its
/// clone helper likewise preserves `xdictionary_handle`; across drawings
/// that handle would dangle into the source document.
#[test]
fn cloned_subs_do_not_share_source_xdict() {
let mut doc = codec::CadDocument::new();
let dict_handle = doc.allocate_handle();
let mut dict = codec::objects::Dictionary::new();
dict.handle = dict_handle;
doc.objects
.insert(dict_handle, codec::objects::ObjectType::Dictionary(dict));
let mut line = codec::entities::Line::new();
line.common.xdictionary_handle = Some(dict_handle);
let src_handle = doc.add_entity(codec::EntityType::Line(line)).unwrap();
let subs = vec![doc.get_entity(src_handle).unwrap().clone()];
let name = define_transformed_block_doc(
&mut doc,
&subs,
&crate::command::EntityTransform::Translate(glam::DVec3::ZERO),
)
.expect("clone block must be created");
assert!(
name.starts_with("*D"),
"anonymous block expected, got {name}"
);
assert_no_shared_xdicts(&doc);
// Source keeps its dictionary.
match doc.get_entity(src_handle) {
Some(e) => assert_eq!(
e.common().xdictionary_handle,
Some(dict_handle),
"the source must keep its own dictionary"
),
None => panic!("source entity missing"),
}
// Clones carry none.
let owned = doc.block_records.get(&name).unwrap().entity_handles.clone();
assert!(!owned.is_empty(), "clone block should own sub-entities");
for h in &owned {
match doc.get_entity(*h) {
Some(e) => assert!(
e.common().xdictionary_handle.is_none(),
"cloned child {h:?} must not carry an extension dictionary"
),
None => panic!("cloned child {h:?} missing"),
}
}
}
}
+9
View File
@@ -1049,6 +1049,15 @@ impl OpenCADStudio {
}
}
} else {
// Attributes are placed through the same base-point convention as
// the render path, so hand the command the definition's base point.
let block_base = crate::scene::render_graph::block_base_point(
&self.tabs[i].scene.document,
&block_name,
);
if let Some(cmd) = self.tabs[i].active_cmd.as_mut() {
cmd.attreq_set_block_base(block_base);
}
let completed = self.tabs[i]
.active_cmd
.as_mut()
+74 -8
View File
@@ -42,6 +42,7 @@ impl OpenCADStudio {
}
let pending = self.begin_undo(i, "EXTRUDE", handles.len(), true);
let mut created_handles = Vec::new();
let mut associative_sources = Vec::new();
let mut consumed = Vec::new();
let mut failed = 0usize;
for handle in &handles {
@@ -112,10 +113,27 @@ impl OpenCADStudio {
entity.surface_data = data;
}
}
match history {
Some(history) => self.add_surface_model_with_history(surface, body, history),
let created = match history {
Some(history) => {
self.add_surface_model_with_history(surface, body, history)
}
None => self.add_surface_model(surface, body),
};
// An associative surface keeps reading its profile.
if sweep_model::surface_associativity() && path.is_none() && !created.is_null() {
use crate::scene::model::surface_sources::{link_surface_sources, SurfaceSources};
let (height, given) = match extent {
ExtrudeExtent::Height(height) => (height, None),
_ => (direction.length(), Some(direction)),
};
link_surface_sources(
&mut self.tabs[i].scene.document,
created,
&SurfaceSources::Extrude { profile: *handle, height, direction: given, taper: taper_angle },
);
associative_sources.push(*handle);
}
created
} else {
let direction = direction.unwrap_or(glam::DVec3::ZERO);
let history = path
@@ -139,7 +157,7 @@ impl OpenCADStudio {
failed += 1;
} else {
created_handles.push(created);
if delete_sources {
if delete_sources && !associative_sources.contains(handle) {
consumed.push(*handle);
}
}
@@ -310,6 +328,7 @@ impl OpenCADStudio {
handles,
axis_start,
axis_end,
axis_object,
angle,
start_angle,
mode,
@@ -346,6 +365,7 @@ impl OpenCADStudio {
self.begin_undo(i, "REVOLVE", editable_handles.len(), true)
};
let mut created_handles = Vec::new();
let mut associative_sources = Vec::new();
let mut consumed = Vec::new();
for handle in &editable_handles {
let Some(entity) = self.tabs[i].scene.document.get_entity(*handle).cloned() else {
@@ -364,10 +384,34 @@ impl OpenCADStudio {
failed += 1;
continue;
};
self.add_surface_model(
empty_revolved_surface(&entity, axis_start, axis_end, angle, start_angle),
let created = self.add_surface_model(
empty_revolved_surface(
&entity,
axis_start,
axis_end,
angle,
start_angle,
),
body,
)
);
// An associative surface keeps reading its profile and axis.
if sweep_model::surface_associativity() && !created.is_null() {
use crate::scene::model::surface_sources::{link_surface_sources, SurfaceSources};
link_surface_sources(
&mut self.tabs[i].scene.document,
created,
&SurfaceSources::Revolve {
profile: *handle,
axis: axis_object,
start: axis_start,
end: axis_end,
angle,
start_angle,
},
);
associative_sources.push(*handle);
}
created
} else {
let result = sweep_model::revolve_history(&entity, from, to, angle, start_angle)
.and_then(|history| {
@@ -391,7 +435,7 @@ impl OpenCADStudio {
failed += 1;
} else {
created_handles.push(created);
if delete_sources {
if delete_sources && !associative_sources.contains(handle) {
consumed.push(*handle);
}
}
@@ -688,6 +732,7 @@ impl OpenCADStudio {
}
let dirty_before = self.tabs[i].dirty;
let pending = self.begin_undo(i, "LOFT", 1, true);
let mut associative = false;
let created = if surface {
let handle =
self.add_surface_model(loft_command_model::surface_entity(&record), body);
@@ -696,6 +741,27 @@ impl OpenCADStudio {
handle,
codec::objects::SolidHistoryOperation::Loft(record),
);
// An associative surface keeps reading its cross
// sections (each section a single object).
let single: Option<Vec<Handle>> = sections
.iter()
.map(|section| match section {
LoftSectionSelection::Entity(handle) => Some(*handle),
_ => None,
})
.collect();
if let Some(single) = single.filter(|_| {
crate::scene::model::sweep_model::surface_associativity()
&& guides.is_empty()
&& path.is_none()
}) {
crate::scene::model::surface_sources::link_surface_sources(
&mut self.tabs[i].scene.document,
handle,
&crate::scene::model::surface_sources::SurfaceSources::Loft { sections: single },
);
associative = true;
}
}
handle
} else {
@@ -713,7 +779,7 @@ impl OpenCADStudio {
self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref());
return Some(Task::none());
} else {
let mut consumed = if delete_sections {
let mut consumed = if delete_sections && !associative {
sources.clone()
} else {
Vec::new()
+275
View File
@@ -1479,3 +1479,278 @@ mod bedit_characterization_tests {
);
}
}
#[cfg(test)]
mod block_base_point_tests {
use super::super::*;
use crate::command::{CadCommand, CmdResult};
use crate::modules::insert::insert_block::InsertBlockCommand;
use codec::entities::AttributeDefinition;
use codec::types::Vector3;
use codec::EntityType;
/// base-point fix (A), host side: `AttreqNeeded` is where the host resolves the
/// block's ATTDEFs, so it also injects the definition's base point. Without
/// that the committed attribute lands on the codec transform's position.
#[test]
fn locked_attreq_injects_the_block_base_point_for_attribute_placement() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
// Block "ATTREQ_BP": base (10,5,0); LINE (10,5)-(30,5); ATTDEF TAG1 at
// (12,8). Rendering convention (see scene::block_base_point_tests): the
// ATTDEF lands at (100,100) + Rot(30)*Scale(2)*((12,8) - (10,5))
// = (100,100) + 2*(1.732051 - 1.5, 1 + 2.598076) = (100.464102, 107.196152).
app.tabs[i].scene.define_block_raw(
"ATTREQ_BP",
Vector3::new(10.0, 5.0, 0.0),
vec![
EntityType::Line(codec::entities::Line::from_points(
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(30.0, 5.0, 0.0),
)),
EntityType::AttributeDefinition(AttributeDefinition {
insertion_point: Vector3::new(12.0, 8.0, 0.0),
height: 1.0,
..AttributeDefinition::new(
"TAG1".to_string(),
"Tag one".to_string(),
String::new(),
)
}),
],
);
let mut cmd = InsertBlockCommand::classic_for_block(
"ATTREQ_BP".to_string(),
Vec::new(),
String::new(),
);
let _ = cmd.on_point(glam::DVec3::new(100.0, 100.0, 0.0));
assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint)));
assert!(matches!(cmd.on_text_input("2"), Some(CmdResult::NeedPoint)));
assert!(matches!(
cmd.on_text_input("30"),
Some(CmdResult::AttreqNeeded { .. })
));
app.tabs[i].active_cmd = Some(Box::new(cmd));
let _ = app.apply_cmd_result(CmdResult::AttreqNeeded {
block_name: "ATTREQ_BP".to_string(),
});
let result = app.tabs[i]
.active_cmd
.as_mut()
.expect("the INSERT command waits for the attribute value")
.on_text_input("VALUE1")
.expect("the value finishes the INSERT");
let _ = app.apply_cmd_result(result);
let attribute = app.tabs[i]
.scene
.document
.entities()
.find_map(|entity| match entity {
EntityType::Insert(ins) if ins.block_name == "ATTREQ_BP" => {
ins.attributes.first().cloned()
}
_ => None,
})
.expect("the committed INSERT's attribute");
assert_eq!(attribute.tag, "TAG1");
let point = attribute.insertion_point;
assert!(
(point.x - 100.464102).abs() < 1e-6
&& (point.y - 107.196152).abs() < 1e-6
&& point.z.abs() < 1e-9,
"attribute at ({:.6},{:.6},{:.6}), expected (100.464102,107.196152,0)",
point.x,
point.y,
point.z
);
}
/// base-point fix (D), host side: picking an existing block in the Block
/// Definition dialog prefills the base point from that definition.
#[test]
fn locked_block_def_name_select_prefills_the_definition_base_point() {
let mut app = OpenCADStudio::new_for_test();
let _ = app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
app.tabs[i].scene.define_block_raw(
"DEF_BASE",
Vector3::new(10.0, 5.0, 0.0),
vec![EntityType::Line(codec::entities::Line::from_points(
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(30.0, 5.0, 0.0),
))],
);
app.block_definition = Some(
crate::ui::window::block_definition::BlockDefinitionState::new(
vec!["DEF_BASE".to_string()],
Vec::new(),
0,
),
);
assert_eq!(
app.block_definition
.as_ref()
.map(|s| s.base_point_y.as_str()),
Some("0.0000"),
"a new block still starts at 0.0000"
);
let _ = app.update(Message::BlockDefNameSelect("DEF_BASE".to_string()));
let state = app.block_definition.as_ref().expect("the dialog is open");
assert_eq!(state.name, "DEF_BASE");
assert_eq!(state.base_point_x, "10.0000");
assert_eq!(state.base_point_y, "5.0000");
assert_eq!(state.base_point_z, "0.0000");
assert_eq!(state.parse_base_point(), glam::DVec3::new(10.0, 5.0, 0.0));
}
/// A block with base (10,5,0) and LINE (10,5)-(30,5), referenced by an
/// INSERT at (100,100) with 30° rotation and uniform scale 2. Under the
/// convention the LINE renders at (100,100)-(134.641016,120).
fn block_and_instance_with_base(app: &mut OpenCADStudio, name: &str) -> codec::Handle {
let _ = app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
app.tabs[i].scene.define_block_raw(
name,
Vector3::new(10.0, 5.0, 0.0),
vec![EntityType::Line(codec::entities::Line::from_points(
Vector3::new(10.0, 5.0, 0.0),
Vector3::new(30.0, 5.0, 0.0),
))],
);
let mut insert =
codec::entities::Insert::new(name.to_string(), Vector3::new(100.0, 100.0, 0.0));
insert.set_x_scale(2.0);
insert.set_y_scale(2.0);
insert.set_z_scale(2.0);
insert.rotation = std::f64::consts::PI / 6.0;
app.tabs[i].scene.add_entity(EntityType::Insert(insert))
}
/// base-point follow-up fix: the REFEDIT working copy is placed through the
/// base-point-aware INSERT transform, so it sits exactly on the rendered
/// reference, and saving folds it back to the same block-local geometry.
#[test]
fn locked_refedit_working_copy_sits_on_the_rendered_reference() {
let mut app = OpenCADStudio::new_for_test();
let handle = block_and_instance_with_base(&mut app, "REFEDIT_BP");
let i = app.active_tab;
let _ = app.dispatch_command(&format!("REFEDIT_BEGIN:{}", handle.value()));
assert!(
app.tabs[i].refedit_session.is_some(),
"setup: REFEDIT_BEGIN opens a session"
);
// The working copy is the LINE the session moved into model space.
let temp: Vec<codec::Handle> = app.tabs[i]
.refedit_session
.as_ref()
.expect("the session")
.temp_handles
.clone();
let line = temp
.iter()
.find_map(|h| match app.tabs[i].scene.document.get_entity(*h) {
Some(EntityType::Line(line)) => Some(line.clone()),
_ => None,
})
.expect("the working-copy LINE");
// (100,100) + Rot(30)*2*((10,5)-(10,5)) and ...*((30,5)-(10,5)).
assert!(
(line.start.x - 100.0).abs() < 1e-6
&& (line.start.y - 100.0).abs() < 1e-9
&& (line.end.x - 134.641016).abs() < 1e-6
&& (line.end.y - 120.0).abs() < 1e-6,
"working copy at ({:.6},{:.6})-({:.6},{:.6}), expected (100,100)-(134.641016,120)",
line.start.x,
line.start.y,
line.end.x,
line.end.y
);
// Saving without editing restores the definition verbatim: the
// inverse is the exact inverse of the forward transform.
let _ = app.dispatch_command("REFCLOSE_SAVE");
assert!(app.tabs[i].refedit_session.is_none());
let stored = app.tabs[i]
.scene
.document
.block_records
.get("REFEDIT_BP")
.expect("the block record")
.entity_handles
.iter()
.find_map(|h| match app.tabs[i].scene.document.get_entity(*h) {
Some(EntityType::Line(line)) => Some(line.clone()),
_ => None,
})
.expect("the stored LINE");
assert!(
(stored.start.x - 10.0).abs() < 1e-9
&& (stored.start.y - 5.0).abs() < 1e-9
&& (stored.end.x - 30.0).abs() < 1e-9
&& (stored.end.y - 5.0).abs() < 1e-9,
"stored LINE at ({:.9},{:.9})-({:.9},{:.9}), expected (10,5)-(30,5)",
stored.start.x,
stored.start.y,
stored.end.x,
stored.end.y
);
assert_eq!(
crate::scene::render_graph::block_base_point(&app.tabs[i].scene.document, "REFEDIT_BP"),
Vector3::new(10.0, 5.0, 0.0),
"the base point survives the round trip"
);
}
/// base-point follow-up fix: the dynamic-block visibility grip is a
/// block-definition coordinate, so it follows the base-point convention
/// too: (100,100) + Rot(30)*2*((12,3) - (10,5)) = (105.464102, 98.535898).
#[test]
fn locked_visibility_grip_follows_the_base_point_convention() {
let mut app = OpenCADStudio::new_for_test();
let handle = block_and_instance_with_base(&mut app, "VIS_BP");
let i = app.active_tab;
let def_block = app.tabs[i]
.scene
.document
.block_records
.get("VIS_BP")
.expect("the block record")
.handle;
let mut param = codec::objects::BlockVisibilityParameter::default();
param.owner = def_block;
param.def_point = Vector3::new(12.0, 3.0, 0.0);
app.tabs[i]
.scene
.document
.block_visibility_params
.insert(codec::Handle::new(0x70), param);
app.tabs[i].selected_handle = Some(handle);
app.refresh_visibility_grip([0.0, 0.0, 0.0]);
let grip = app.tabs[i]
.selected_grips
.last()
.expect("the visibility lookup grip");
assert!(
(grip.world.x - 105.464102).abs() < 1e-6 && (grip.world.y - 98.535898).abs() < 1e-6,
"grip at ({:.6},{:.6}), expected (105.464102,98.535898)",
grip.world.x,
grip.world.y
);
assert!(
app.tabs[i].visibility_grip.is_some(),
"the selection is a dynamic-block reference"
);
}
}
+6 -16
View File
@@ -602,6 +602,7 @@ impl OpenCADStudio {
if !self.record_accepted_snap(i, None, None, *point) {
return (Task::none(), true);
}
self.snapper.clear_override();
}
if default_start {
let StepInput::Point(point) = &input else {
@@ -1120,26 +1121,15 @@ impl OpenCADStudio {
let is_mtp = token.trim_start_matches('_').eq_ignore_ascii_case("MTP")
|| token.trim_start_matches('_').eq_ignore_ascii_case("M2P");
if is_mtp {
let is_point_step = !self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.input_kind().wants_text())
.unwrap_or(true)
|| self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.point_step_accepts_keywords())
.unwrap_or(false);
let not_entity_pick = !self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false);
if is_point_step && not_entity_pick {
if self.point_modifier_step(i) {
self.start_mtp_modifier(i);
return Task::none();
}
}
if let Some(kind) = self.typed_snap_override(i, token) {
self.apply_snap_override(kind);
return Task::none();
}
if let Some((coord, kind)) = crate::app::helpers::parse_coord(token) {
// Match the GUI command line: typed coordinates are in the active
// UCS (relative offsets are rotated by the UCS axes), so a multi-
+26 -17
View File
@@ -929,18 +929,7 @@ impl OpenCADStudio {
if self.show_external_references {
// Always open expanded so the panel is immediately usable;
// dock it on the right if the user closed it from the layout.
if self
.dock
.location(crate::ui::dock::PanelId::ExternalReferences)
.is_none()
{
self.dock.dock(
crate::ui::dock::PanelId::ExternalReferences,
crate::app::config::DockSide::Right,
usize::MAX,
);
}
self.dock_expanded = Some(crate::ui::dock::PanelId::ExternalReferences);
self.dock_open_at_default(crate::ui::dock::PanelId::ExternalReferences);
self.refresh_xref_manager();
}
}
@@ -1623,9 +1612,20 @@ impl OpenCADStudio {
"XRELOAD" => {
// Reload all xrefs for the current drawing. Every direct key
// leaves the session-unloaded set (reloaded content is live
// again) with fresh stat baselines on the next refresh.
if let Some(path) = &self.tabs[i].current_path.clone() {
if let Some(base_dir) = path.parent() {
// again) with fresh stat baselines on the next refresh. Web:
// xrefs re-read from the in-session bytes registry, so an
// unsaved drawing reloads too; native needs the drawing's own
// folder for relative xref paths.
#[cfg(target_arch = "wasm32")]
let base_dir: Option<std::path::PathBuf> = Some(std::path::PathBuf::new());
#[cfg(not(target_arch = "wasm32"))]
let base_dir: Option<std::path::PathBuf> = self.tabs[i]
.current_path
.as_ref()
.and_then(|path| path.parent().map(std::path::Path::to_path_buf));
if let Some(base_dir) = base_dir {
{
let base_dir: &std::path::Path = &base_dir;
let reload_keys: Vec<u64> = crate::io::xref::collect_entries_with_prev(
&self.tabs[i].scene.document,
base_dir,
@@ -1642,7 +1642,7 @@ impl OpenCADStudio {
}
let (infos, _dropped) = crate::io::xref::resolve_xrefs(
&mut self.tabs[i].scene.document,
base_dir,
&base_dir,
);
// `resolve_xrefs` re-merges ALL references — refresh stat
// baselines for every currently-Loaded entry so no row
@@ -1663,8 +1663,17 @@ impl OpenCADStudio {
}
}
for info in &infos {
// Web: unresolved references surface once below,
// with the drop hint, instead of a filesystem path
// no browser can open.
#[cfg(target_arch = "wasm32")]
if info.status == crate::io::xref::XrefStatus::NotFound {
continue;
}
self.report_xref_status(info);
}
#[cfg(target_arch = "wasm32")]
self.warn_unresolved_xrefs(&infos);
self.post_ref_op(i);
}
} else {
@@ -1784,7 +1793,7 @@ mod tests {
let _ = app.run_command_line("BLOCKPALETTE");
assert!(app.show_block_palette);
assert!(
app.dock_expanded == Some(crate::ui::dock::PanelId::BlockPalette),
app.dock_peek == Some(crate::ui::dock::PanelId::BlockPalette),
"palette must open expanded, not as the collapsed bar"
);
// BLOCKSPALETTE only opens; BLOCKSPALETTECLOSE closes.
+3 -7
View File
@@ -259,13 +259,9 @@ impl OpenCADStudio {
self.count_palette.show = open;
self.ribbon.set_count_palette(open);
if open {
if self.dock.location(id).is_none() {
self.dock
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(id);
} else if self.dock_expanded == Some(id) {
self.dock_expanded = None;
self.dock_open_at_default(id);
} else {
self.dock_unpeek(id);
}
}
+85 -41
View File
@@ -500,6 +500,11 @@ impl OpenCADStudio {
return Some(Task::done(Message::ToggleProperties));
}
// ── GRAPHICATTRIBUTES — toggle Graphic Attributes palette ───────────
"GRAPHICATTRIBUTES" => {
return Some(Task::done(Message::ToggleGraphicAttributes));
}
// ── FILETAB — toggle file/document tabs ──────────────────────────────
"FILETAB" => {
return Some(Task::done(Message::ToggleFileTabs));
@@ -967,7 +972,18 @@ impl OpenCADStudio {
self.command_line.push_output(&line);
}
}
"EXPORT" | "EXPORTPDF" => {
"EXPORT" => {
return Some(Task::done(Message::PlotExport));
}
"EXPORTPDF" => {
self.plot_dialog.file_svg = false;
return Some(Task::done(Message::PlotExport));
}
// EXPORTSVG — the same direct export, but the save dialog
// proposes the SVG device's defaults (`stem.svg`).
"EXPORTSVG" => {
self.plot_dialog.to_file = true;
self.plot_dialog.file_svg = true;
return Some(Task::done(Message::PlotExport));
}
// PLOTSTYLE — load or clear CTB/STB plot style table
@@ -1783,51 +1799,34 @@ impl OpenCADStudio {
.push_info(crate::t!("Usage: DATALINK <path-to-.csv>").as_ref());
return Some(Task::none());
}
// Web: nothing is behind a typed path — pick the .csv through
// the browser, read its bytes at pick time, and register them
// in the picked-source registry; the same table-insert path
// then runs from `DataLinkCsvPicked`.
#[cfg(target_arch = "wasm32")]
{
// Say the typed argument is superseded — a silent
// redirect looks like the input was lost.
if !path.trim().is_empty() {
self.command_line.push_info(
crate::t!(
"The browser cannot open a typed path; pick the file instead."
)
.as_ref(),
);
}
return Some(Task::perform(
crate::io::pick_datalink_csv_web(),
Message::DataLinkCsvPicked,
));
}
#[cfg(not(target_arch = "wasm32"))]
match std::fs::read_to_string(path) {
Ok(text) => {
let rows_data = parse_csv_table(&text);
let nrows = rows_data.len();
let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0);
if nrows == 0 || ncols == 0 {
self.command_line
.push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref());
return Some(Task::none());
}
use codec::entities::TableBuilder;
use codec::types::Vector3;
let mut table = TableBuilder::new(nrows, ncols)
.at(Vector3::new(0.0, 0.0, 0.0))
.row_height(0.5)
.column_width(2.0)
.build();
for (r, row) in rows_data.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
table.set_cell_text(r, c, cell);
}
}
let doc = &self.tabs[i].scene.document;
let current_style = doc.header.current_table_style_name.clone();
table.table_style_handle =
doc.objects
.iter()
.find_map(|(handle, object)| match object {
codec::objects::ObjectType::TableStyle(style)
if style.name.eq_ignore_ascii_case(&current_style) =>
{
Some(*handle)
}
_ => None,
});
let link_path = std::fs::canonicalize(path)
.map(|value| value.to_string_lossy().into_owned())
.unwrap_or_else(|_| path.to_string());
let command =
crate::modules::annotate::data_link::DataLinkPlaceCommand::new(
table, &link_path,
);
self.command_line
.push_info(&crate::command::CadCommand::prompt(&command));
self.tabs[i].active_cmd = Some(Box::new(command));
self.place_data_link_table(&text, &link_path);
}
Err(e) => {
self.command_line.push_error(
@@ -2060,6 +2059,51 @@ impl OpenCADStudio {
}
Some(self.finish_dispatch(cmd))
}
/// Build the linked table from CSV text and start the placement command —
/// the shared tail of the native typed-path `DATALINK` read and the web
/// picker's `DataLinkCsvPicked` (which registers the picked bytes in the
/// picked-source registry first so the link re-reads in-session).
pub(in crate::app) fn place_data_link_table(&mut self, csv_text: &str, link_path: &str) {
let i = self.active_tab;
let rows_data = parse_csv_table(csv_text);
let nrows = rows_data.len();
let ncols = rows_data.iter().map(|r| r.len()).max().unwrap_or(0);
if nrows == 0 || ncols == 0 {
self.command_line
.push_error(crate::t!("DATALINK: the CSV file is empty.").as_ref());
return;
}
use codec::entities::TableBuilder;
use codec::types::Vector3;
let mut table = TableBuilder::new(nrows, ncols)
.at(Vector3::new(0.0, 0.0, 0.0))
.row_height(0.5)
.column_width(2.0)
.build();
for (r, row) in rows_data.iter().enumerate() {
for (c, cell) in row.iter().enumerate() {
table.set_cell_text(r, c, cell);
}
}
let doc = &self.tabs[i].scene.document;
let current_style = doc.header.current_table_style_name.clone();
table.table_style_handle = doc.objects.iter().find_map(|(handle, object)| {
match object {
codec::objects::ObjectType::TableStyle(style)
if style.name.eq_ignore_ascii_case(&current_style) =>
{
Some(*handle)
}
_ => None,
}
});
let command =
crate::modules::annotate::data_link::DataLinkPlaceCommand::new(table, link_path);
self.command_line
.push_info(&crate::command::CadCommand::prompt(&command));
self.tabs[i].active_cmd = Some(Box::new(command));
}
}
// Scan LandXML text for <CgPoint> survey points. Each element's text content is
+406 -82
View File
@@ -80,10 +80,11 @@ impl OpenCADStudio {
}
#[cfg(target_arch = "wasm32")]
{
self.command_line.push_info(
crate::t!("SAVEALL: save each tab individually in the web build.").as_ref(),
);
// Web: every dirty saved tab is serialized and downloaded
// in one pass — see `saveall_web` for the flow.
return Some(self.saveall_web());
}
#[cfg(not(target_arch = "wasm32"))]
return Some(Task::none());
}
"SAVEAS" => return Some(Task::done(Message::SaveAs)),
@@ -169,71 +170,86 @@ impl OpenCADStudio {
// ARCHIVE / ETRANSMIT — package the drawing and its referenced files
// (xrefs + raster images) into a sibling "<name>_archive" folder.
// Web: there is no folder to copy into — the same dependency set is
// collected from the session's bytes registries and delivered as a
// "<name>_transmit.zip" download (`archive_web` below).
"ARCHIVE" | "ETRANSMIT" => {
use std::path::PathBuf;
if let Some(src) = self.tabs[i].current_path.clone() {
let stem = src
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let parent = src
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let folder = parent.join(format!("{stem}_archive"));
match std::fs::create_dir_all(&folder) {
Ok(()) => {
let mut copied = 0usize;
if let Some(fname) = src.file_name() {
if std::fs::copy(&src, folder.join(fname)).is_ok() {
copied += 1;
}
}
let mut deps: Vec<String> = Vec::new();
for br in self.tabs[i].scene.document.block_records.iter() {
if !br.xref_path.trim().is_empty() {
deps.push(br.xref_path.clone());
}
}
for e in self.tabs[i].scene.document.entities() {
if let codec::EntityType::RasterImage(img) = e {
if !img.file_path.trim().is_empty() {
deps.push(img.file_path.clone());
#[cfg(not(target_arch = "wasm32"))]
{
use std::path::PathBuf;
if let Some(src) = self.tabs[i].current_path.clone() {
let stem = src
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let parent = src
.parent()
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from("."));
let folder = parent.join(format!("{stem}_archive"));
match std::fs::create_dir_all(&folder) {
Ok(()) => {
let mut copied = 0usize;
if let Some(fname) = src.file_name() {
if std::fs::copy(&src, folder.join(fname)).is_ok() {
copied += 1;
}
}
}
for d in deps {
let dp = PathBuf::from(&d);
let resolved = if dp.is_absolute() {
dp
} else {
parent.join(&dp)
};
if resolved.exists() {
if let Some(fname) = resolved.file_name() {
if std::fs::copy(&resolved, folder.join(fname)).is_ok() {
copied += 1;
let mut deps: Vec<String> = Vec::new();
for br in self.tabs[i].scene.document.block_records.iter() {
if !br.xref_path.trim().is_empty() {
deps.push(br.xref_path.clone());
}
}
for e in self.tabs[i].scene.document.entities() {
if let codec::EntityType::RasterImage(img) = e {
if !img.file_path.trim().is_empty() {
deps.push(img.file_path.clone());
}
}
}
for d in deps {
let dp = PathBuf::from(&d);
let resolved = if dp.is_absolute() {
dp
} else {
parent.join(&dp)
};
if resolved.exists() {
if let Some(fname) = resolved.file_name() {
if std::fs::copy(&resolved, folder.join(fname)).is_ok()
{
copied += 1;
}
}
}
}
self.command_line.push_output(
crate::tf!(
"{cmd}: packaged {copied} file(s) into {}",
folder.display()
)
.as_ref(),
);
}
self.command_line.push_output(
crate::tf!(
"{cmd}: packaged {copied} file(s) into {}",
folder.display()
)
.as_ref(),
);
Err(e) => self.command_line.push_error(
crate::tf!("{cmd}: cannot create folder ({e}).").as_ref(),
),
}
Err(e) => self
.command_line
.push_error(crate::tf!("{cmd}: cannot create folder ({e}).").as_ref()),
} else {
self.command_line.push_error(
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).")
.as_ref(),
);
}
} else {
self.command_line.push_error(
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).")
.as_ref(),
);
}
#[cfg(target_arch = "wasm32")]
{
// The drawing serialization runs on the serializer
// worker; the ZIP assembly + download continue in the
// completion message (`archive_web` below).
let task = self.archive_web(i, cmd);
return Some(Task::batch([task, self.finish_dispatch(cmd)]));
}
}
@@ -384,7 +400,9 @@ impl OpenCADStudio {
"ORTHO" => return Some(Task::done(Message::ToggleOrtho)),
"PARALLEL" => return Some(Task::done(Message::SetProjection(true))),
"PERSP" => return Some(Task::done(Message::SetProjection(false))),
"LAYERS" => return Some(Task::done(Message::ToggleLayers)),
// LAYERS opens the Layer Manager pallet (its close button or
// the toggle message close it).
"LAYERS" => self.open_layers_pallet(),
// SCRIPT <path> — run a command script: each non-comment
// line is fed through the same command path the `--script` startup
@@ -418,30 +436,30 @@ impl OpenCADStudio {
let path = cmd.split_once(' ').map(|(_, r)| r.trim().to_string());
match path {
Some(p) if !p.is_empty() => {
let clean_path = p.trim().trim_matches(|c| c == '"' || c == '\'');
match std::fs::read_to_string(clean_path) {
Ok(text) => {
let cmds: Vec<Task<Message>> = text
.lines()
.map(str::trim)
.filter(|l| !l.starts_with('#') && !l.starts_with(';'))
.map(|l| Task::done(Message::ScriptLine(l.to_string())))
.collect();
self.command_line.push_output(
crate::tf!(
"SCRIPT: running {} command(s) from {clean_path}.",
cmds.len()
)
.as_ref(),
);
return Some(Task::batch(cmds));
}
Err(e) => {
self.command_line.push_error(
crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref(),
);
#[cfg(not(target_arch = "wasm32"))]
{
let clean_path = p.trim().trim_matches(|c| c == '"' || c == '\'');
match std::fs::read_to_string(clean_path) {
Ok(text) => return Some(self.run_script(clean_path, &text)),
Err(e) => {
self.command_line.push_error(
crate::tf!("SCRIPT: cannot read {clean_path}: {e}").as_ref(),
);
}
}
}
// Web: nothing is behind a typed path — pick the
// script through the browser and read its bytes at
// pick time (the `pick_and_load_web` pattern); the
// picked text is fed to the same runner.
#[cfg(target_arch = "wasm32")]
{
let _ = &p;
return Some(Task::perform(
crate::io::pick_script_web(),
Message::ScriptFilePicked,
));
}
}
_ => {
self.command_line
@@ -454,6 +472,53 @@ impl OpenCADStudio {
}
Some(self.finish_dispatch(cmd))
}
/// Announce a script and feed its rows to the same runner the `--script`
/// startup flag uses — shared by the native `SCRIPT <path>` read and the
/// web picker's `ScriptFilePicked`.
pub(in crate::app) fn run_script(&mut self, path: &str, text: &str) -> Task<Message> {
let p = path;
let cmds: Vec<Task<Message>> = script_command_lines(text)
.into_iter()
.map(|line| Task::done(Message::ScriptLine(line)))
.collect();
self.command_line.push_output(
crate::tf!("SCRIPT: running {} command(s) from {p}.", cmds.len()).as_ref(),
);
Task::batch(cmds)
}
}
/// One runnable row of a `.scr` script: trimmed, comment rows (`#`, `;`)
/// dropped, blank rows kept so they submit Enter to the active command.
fn script_command_lines(text: &str) -> Vec<String> {
text.lines()
.map(str::trim)
.filter(|line| !line.starts_with('#') && !line.starts_with(';'))
.map(str::to_string)
.collect()
}
#[cfg(test)]
mod script_tests {
use super::script_command_lines;
/// Comments drop, every other row survives trimmed, and blank or
/// whitespace-only rows stay as empty strings (Enter presses).
#[test]
fn comments_and_blanks_follow_the_scr_rules() {
let lines =
script_command_lines("# comment\n; also a comment\nLINE 0,0 10,0\n\n \n TRIM \n");
assert_eq!(
lines,
vec![
"LINE 0,0 10,0".to_string(),
String::new(),
String::new(),
"TRIM".to_string(),
]
);
}
}
/// Parse the argument list of the `BACKGROUND` command into an `[r,g,b,a]`
@@ -496,6 +561,265 @@ fn parse_background_color(args: &[&str]) -> Option<[f32; 4]> {
None
}
// ── Web ARCHIVE / ETRANSMIT ──────────────────────────────────────────────────
//
// The browser has no folder to copy into, so the transmittal is assembled in
// memory and downloaded as one ZIP — the same dependency set the native
// folder walk collects (drawing, xrefs, raster images) plus the web-only
// bytes-backed kinds (PDF underlays, session-picked plot tables). Anything
// whose bytes never entered the session is reported as skipped rather than
// silently dropped, the web counterpart of native's missing-file skip.
#[cfg(target_arch = "wasm32")]
impl OpenCADStudio {
pub(in crate::app) fn archive_web(&mut self, i: usize, cmd: &str) -> Task<Message> {
let Some(src) = self.tabs[i].current_path.clone() else {
self.command_line.push_error(
crate::t!("ARCHIVE: save the drawing first (it has no file path yet).").as_ref(),
);
return Task::none();
};
let stem = src
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "drawing".into());
let ext = src
.extension()
.map(|e| e.to_string_lossy().into_owned())
.unwrap_or_else(|| "dwg".into());
let filename = src
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| format!("{stem}.{ext}"));
let version = self.tabs[i].scene.document.version;
// The same save preparation a web Save performs — without it the
// archived copy ships kernel solids without their ACIS data and a
// staler view/sysvar header than a real save would produce.
self.sync_view_state_for_save(i);
crate::app::update::util::sync_annotation_scale_header(&mut self.tabs[i].scene);
self.stamp_header_sysvars(i);
self.sync_solid_models_for_save(i);
self.tabs[i].scene.sync_native_parametric_graph();
// The dependency set (xrefs, raster images, PDF underlays, plot
// tables) is collected here, at command time — cheap in-memory
// registry reads — so the transmittal matches the drawing snapshot
// serialized below even if the tab changes while the writer runs.
let mut deps: Vec<(String, Vec<u8>)> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
// Xrefs — the block-record walk native copies; the bytes come from
// the session's xref registry (picked, dropped or opened files).
for br in self.tabs[i].scene.document.block_records.iter() {
if br.xref_path.trim().is_empty() {
continue;
}
match crate::io::xref::find_registered_source(&br.xref_path)
.and_then(|key| crate::io::xref::registered_bytes(&key))
{
Some(bytes) => push_entry(
&mut deps,
transmit_file_name(&br.xref_path),
(*bytes).clone(),
),
None => push_skipped(&mut skipped, &transmit_file_name(&br.xref_path)),
}
}
// Raster images — picked/dropped bytes registered for the reference
// stand in for the file native would copy.
for e in self.tabs[i].scene.document.entities() {
if let codec::EntityType::RasterImage(img) = e {
if img.file_path.trim().is_empty() {
continue;
}
match crate::scene::model::image_model::registered_source_bytes(&img.file_path) {
Some(bytes) => push_entry(
&mut deps,
transmit_file_name(&img.file_path),
(*bytes).clone(),
),
None => push_skipped(&mut skipped, &transmit_file_name(&img.file_path)),
}
}
}
// PDF underlays — bytes registered at attach time.
for o in self.tabs[i].scene.document.objects.values() {
if let codec::objects::ObjectType::UnderlayDefinition(def) = o {
if !matches!(def.underlay_type, codec::entities::UnderlayType::Pdf)
|| def.file_path.trim().is_empty()
{
continue;
}
match crate::scene::model::pdf_raster::registered_source_bytes(&def.file_path) {
Some(bytes) => push_entry(
&mut deps,
transmit_file_name(&def.file_path),
(*bytes).clone(),
),
None => push_skipped(&mut skipped, &transmit_file_name(&def.file_path)),
}
}
}
// Plot tables named by the layouts (and the session's active one).
// Only tables picked this session have bytes; the built-ins ship
// with the app, so they are neither packaged nor reported missing.
let mut style_names: Vec<String> = self.tabs[i]
.scene
.document
.objects
.values()
.filter_map(|o| {
if let codec::objects::ObjectType::Layout(l) = o {
Some(l.plot_style_sheet.clone())
} else {
None
}
})
.collect();
if let Some(table) = self.active_plot_style.as_ref() {
style_names.push(table.name.clone());
}
for name in style_names {
if name.trim().is_empty()
|| crate::io::plot_style::PlotStyleTable::builtin(&name).is_ok()
{
continue;
}
match crate::io::plot_style::session_table(&name) {
Some(bytes) => push_entry(&mut deps, name, (*bytes).clone()),
None => push_skipped(&mut skipped, &name),
}
}
// The drawing itself: the same worker-routed serialization a web
// Save performs (native copies the last-saved file; the web build
// has none, so the current document is re-serialized). The ZIP
// assembly and download are cheap and stay on the main thread —
// they continue in the completion message.
let zip_name = format!("{stem}_transmit.zip");
let prepared =
crate::io::prepare_document_for_save(self.tabs[i].scene.document_for_save(), version);
let cmd = cmd.to_string();
Task::perform(
async move { crate::io::serialize_prepared_document(&prepared, &ext).await },
move |result| Message::WebArchiveSerialized {
cmd,
zip_name,
filename,
deps,
skipped,
result,
},
)
}
/// Web ARCHIVE completion: the serializer worker (or the inline
/// fallback) produced the drawing bytes. Assemble the transmittal ZIP —
/// the drawing entry first, then the dependencies under the same
/// name-collision rule the synchronous pass used — download it and
/// report, including the skipped-dependency lines.
pub(in crate::app) fn on_web_archive_serialized(
&mut self,
cmd: String,
zip_name: String,
filename: String,
deps: Vec<(String, Vec<u8>)>,
skipped: Vec<String>,
result: Result<Vec<u8>, String>,
) -> Task<Message> {
let drawing = match result {
Ok(bytes) => bytes,
Err(e) => {
self.command_line.push_error(
crate::tf!("{cmd}: cannot package the drawing ({e}).").as_ref(),
);
return Task::none();
}
};
let mut entries: Vec<(String, Vec<u8>)> = vec![(filename, drawing)];
for (name, data) in deps {
push_entry(&mut entries, name, data);
}
match build_zip(&entries) {
Ok(zip) => {
crate::sys::download_bytes(&zip_name, &zip);
// The same report shape the native folder transmittal makes.
let copied = entries.len();
self.command_line.push_output(
crate::tf!("{cmd}: packaged {copied} file(s) into {}", zip_name).as_ref(),
);
}
Err(e) => self.command_line.push_error(
crate::tf!("{cmd}: cannot build the transmittal ZIP ({e}).").as_ref(),
),
}
if !skipped.is_empty() {
let n = skipped.len();
let names = skipped.join(", ");
self.command_line.push_output(
crate::tf!("{cmd}: skipped {n} referenced file(s) not loaded in this session: {names}")
.as_ref(),
);
}
Task::none()
}
}
/// Trailing component of a reference path — the flat name a transmittal
/// packages it under (native copies `file_name()` into one folder).
#[cfg(target_arch = "wasm32")]
fn transmit_file_name(path: &str) -> String {
path.trim()
.rsplit(['/', '\\'])
.next()
.unwrap_or_default()
.to_string()
}
/// Add one entry under its flat name; a later dependency with the same name
/// replaces the bytes (native's `fs::copy` overwrite into the folder).
#[cfg(target_arch = "wasm32")]
fn push_entry(entries: &mut Vec<(String, Vec<u8>)>, name: String, data: Vec<u8>) {
if let Some(slot) = entries
.iter_mut()
.find(|(entry, _)| entry.eq_ignore_ascii_case(&name))
{
slot.1 = data;
} else {
entries.push((name, data));
}
}
/// Pack the transmittal entries into one deflated ZIP archive in memory.
#[cfg(target_arch = "wasm32")]
fn build_zip(entries: &[(String, Vec<u8>)]) -> Result<Vec<u8>, String> {
use std::io::Write;
let mut writer = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for (name, data) in entries {
writer
.start_file(name.as_str(), options)
.map_err(|e| e.to_string())?;
writer.write_all(data).map_err(|e| e.to_string())?;
}
writer
.finish()
.map(std::io::Cursor::into_inner)
.map_err(|e| e.to_string())
}
/// Remember a missing dependency for the report, once per name.
#[cfg(target_arch = "wasm32")]
fn push_skipped(skipped: &mut Vec<String>, name: &str) {
if !name.is_empty() && !skipped.iter().any(|s| s.eq_ignore_ascii_case(name)) {
skipped.push(name.to_string());
}
}
#[cfg(test)]
mod tests {
use crate::app::{Message, OpenCADStudio};
+15 -5
View File
@@ -809,12 +809,19 @@ impl OpenCADStudio {
};
// Build the INSERT's full placement transform (OCS + rotation +
// scale, including non-uniform / mirrored) and its inverse, so
// edits round-trip back to block-local coordinates on SAVE.
// scale, including non-uniform / mirrored, minus the block base
// point) and its inverse, so edits round-trip back to
// block-local coordinates on SAVE. The base point is part of
// the placement: without it the working copy sits Rot*Scale*base
// away from the reference the user picked.
let sx = insert.x_scale();
let sy = insert.y_scale();
let sz = insert.z_scale();
let forward = insert.get_transform();
let base = crate::scene::render_graph::block_base_point(
&self.tabs[i].scene.document,
&insert.block_name,
);
let forward = crate::scene::render_graph::insert_transform_with_base(&insert, base);
let inverse = {
use codec::types::{Matrix3, Matrix4, Transform};
let ocs_t =
@@ -826,8 +833,11 @@ impl OpenCADStudio {
);
let r_inv = Matrix4::rotation_z(-insert.rotation);
let s_inv = Matrix4::scaling(1.0 / sx, 1.0 / sy, 1.0 / sz);
// inverse(OCS·T·R·S) = S⁻¹·R⁻¹·T⁻¹·OCSᵀ
Transform::from_matrix(s_inv * r_inv * t_inv * ocs_t)
// inverse(T(-base)·OCS·T·R·S) = T(+base)·S⁻¹·R⁻¹·T⁻¹·OCSᵀ
Transform::from_matrix(
Matrix4::translation(base.x, base.y, base.z)
* (s_inv * r_inv * t_inv * ocs_t),
)
};
// Find the block record.
+23 -46
View File
@@ -704,15 +704,7 @@ impl OpenCADStudio {
// rearrange themselves on upgrade for a panel they have
// not asked for.
let id = crate::ui::dock::PanelId::Browser;
if self.dock.location(id).is_none() {
// dock() clamps an out-of-range index to the end of
// the edge, so this appends below whatever is there.
self.dock
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
// Open expanded so it is usable straight away; the pin
// button still collapses it.
self.dock_expanded = Some(id);
self.dock_open_at_default(id);
self.command_line
.push_output(crate::t!("Browser opened.").as_ref());
} else {
@@ -787,6 +779,22 @@ impl OpenCADStudio {
}
}
// -VIEW: the command-line form of VIEW (modules/view/view_cmd.rs).
// The command gathers the input; `-VIEW >…` carries out what it
// gathered. A whole line (`-VIEW _O _FRONT`) feeds its words to
// the command one by one, as typed.
"-VIEW" => {
use crate::command::CadCommand;
let c = crate::modules::view::view_cmd::DashViewCommand::new(self.dash_view_names(i));
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
}
cmd if cmd.starts_with("-VIEW >") => {
return Some(self.run_dash_view(&cmd["-VIEW >".len()..], i));
}
cmd if cmd.starts_with("-VIEW ") => {
return Some(self.run_command_line(cmd));
}
"VIEW" => {
use crate::command::KeywordCommand;
let c = KeywordCommand::new(
@@ -910,45 +918,14 @@ impl OpenCADStudio {
}
}
}
// Standard orientation presets — snap the camera to a world
// axis view (these names take precedence over a same-named
// saved view, matching the standard orientation behaviour).
"TOP" | "FRONT" | "BACK" | "LEFT" | "RIGHT" | "BOTTOM" => {
use crate::scene::pipeline::viewcube::{
FACE_BACK, FACE_BOTTOM, FACE_FRONT, FACE_LEFT, FACE_RIGHT, FACE_TOP,
};
let face = match sub.as_str() {
"TOP" => FACE_TOP,
"BOTTOM" => FACE_BOTTOM,
"FRONT" => FACE_FRONT,
"BACK" => FACE_BACK,
"RIGHT" => FACE_RIGHT,
_ => FACE_LEFT,
};
return Some(Task::done(Message::ViewCubeSnapWorld(
crate::scene::CubeRegion::Face(face),
)));
// Standard orientation presets: the world views -VIEW sets,
// with the orthographic UCS while UCSORTHO is on. These
// names take precedence over a same-named saved view.
"TOP" | "FRONT" | "BACK" | "LEFT" | "RIGHT" | "BOTTOM" | "SWISO" | "SEISO" | "NEISO" | "NWISO" => {
return Some(self.apply_view_preset(&sub));
}
// Isometric presets snap to the matching ViewCube corner;
// ISO alone is the south-west one.
"ISO" | "ISOMETRIC" | "SWISO" | "SEISO" | "NEISO" | "NWISO" => {
let (x, y) = match sub.as_str() {
"SEISO" => (1.0, -1.0),
"NEISO" => (1.0, 1.0),
"NWISO" => (-1.0, 1.0),
_ => (-1.0, -1.0),
};
let want = glam::Vec3::new(x, y, 1.0).normalize();
let corner = (18..26)
.map(crate::scene::CubeRegion::Corner)
.max_by(|a, b| {
a.snap_direction()
.dot(want)
.total_cmp(&b.snap_direction().dot(want))
})
.expect("the cube has corners");
return Some(Task::done(Message::ViewCubeSnap(corner)));
}
"ISO" | "ISOMETRIC" => return Some(self.apply_view_preset("SWISO")),
// VIEW <name> shortcut for restore
_ => {
let name = sub.clone();
+4
View File
@@ -3,6 +3,10 @@ use super::*;
impl OpenCADStudio {
pub(super) fn dispatch_layers(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
cmd if cmd.starts_with("GRAPHICATTRIBUTELAYER ") => {
let name = cmd.trim_start_matches("GRAPHICATTRIBUTELAYER").trim();
return Some(self.create_graphic_attributes_layer(name));
}
// ── Layer object commands ──────────────────────────────────────
"LAYOFF" => {
let handles: Vec<_> = self.tabs[i]
+5
View File
@@ -26,6 +26,7 @@ mod point_cloud;
pub(crate) mod sheet_set;
mod styleprops;
mod view;
mod view_presets;
mod xclip;
mod xref_attach;
@@ -74,6 +75,7 @@ impl OpenCADStudio {
// A new command collects its own points, so the previous command's
// accepted snaps must not leak into it.
self.clear_accepted_snaps();
self.snapper.clear_override();
// Starting a command restarts the right-click cycle, so its first
// right-click acts as Enter rather than opening the context menu.
self.tabs[i]
@@ -466,6 +468,7 @@ inventory::submit!(crate::command::CommandRegistration {
"DSETTINGS",
"PARAMETERS",
"GRID",
"GRAPHICATTRIBUTES",
"ISODRAFT",
"ISOPLANE",
"OSNAP",
@@ -484,6 +487,7 @@ inventory::submit!(crate::command::CommandRegistration {
// Standard aliases for existing commands.
"BMAKE",
"EXPORTPDF",
"EXPORTSVG",
"PRINTALL",
"DDIM",
// Inquiry: list the whole drawing database.
@@ -924,6 +928,7 @@ inventory::submit!(crate::command::CommandRegistration {
"USERI",
"USERR",
"VIEW",
"-VIEW",
"VPORTS",
"VS",
"VW",
+2 -8
View File
@@ -444,17 +444,11 @@ impl OpenCADStudio {
"POINTCLOUDMANAGER" => {
let id = crate::ui::dock::PanelId::PointCloudManager;
self.pc_manager.show = true;
if self.dock.location(id).is_none() {
self.dock
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(id);
self.dock_open_at_default(id);
}
"POINTCLOUDMANAGERCLOSE" => {
self.pc_manager.show = false;
if self.dock_expanded == Some(crate::ui::dock::PanelId::PointCloudManager) {
self.dock_expanded = None;
}
self.dock_unpeek(crate::ui::dock::PanelId::PointCloudManager);
}
"_PCUNCROP" => self
.edit_selected_point_clouds(i, "POINTCLOUDUNCROP", |data| data.croppings.clear()),
+4 -8
View File
@@ -660,13 +660,9 @@ impl OpenCADStudio {
self.sheet_set.show = on;
self.ribbon.set_sheet_set(on);
if on {
if self.dock.location(id).is_none() {
self.dock
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(id);
} else if self.dock_expanded == Some(id) {
self.dock_expanded = None;
self.dock_open_at_default(id);
} else {
self.dock_unpeek(id);
}
}
@@ -2273,8 +2269,8 @@ impl OpenCADStudio {
let host = self.tabs[i].scene.document.header.insertion_units;
let k = crate::app::properties::insert_unit_scale(host, units).unwrap_or(1.0);
let mut insert = codec::entities::Insert::new(name.to_string(), at).with_scale(k, k, k);
let transform = insert.get_transform();
let doc = &self.tabs[i].scene.document;
let transform = crate::scene::render_graph::insert_transform(doc, &insert);
let attdefs: Vec<codec::entities::AttributeDefinition> = doc
.block_records
.get(name)
+94 -107
View File
@@ -358,62 +358,6 @@ impl OpenCADStudio {
.map(|s| s.to_uppercase())
.collect();
// Live blocks are reachable from layouts and style roots
// through the shared graph. Dangling layouts do not count.
let live_blocks: rustc_hash::FxHashSet<String> = {
let doc = &self.tabs[i].scene.document;
let is_real_layout = |br: &codec::BlockRecord| -> bool {
let up = br.name.to_ascii_uppercase();
up.starts_with("*MODEL_SPACE")
|| up.starts_with("*PAPER_SPACE")
|| matches!(
doc.objects.get(&br.layout),
Some(codec::objects::ObjectType::Layout(_))
)
};
let children = |name: &str| -> Vec<String> {
let mut out = Vec::new();
let Some(br) = doc.block_records.get(name) else {
return out;
};
for &h in &br.entity_handles {
let Some(entity) = doc.get_entity(h) else {
continue;
};
out.extend(
crate::scene::render_graph::entity_block_uses(doc, entity, 1.0)
.into_iter()
.filter(|block_use| !block_use.block.is_null())
.map(|block_use| block_use.insert.block_name),
);
}
out
};
let mut live = rustc_hash::FxHashSet::default();
let mut stack: Vec<String> = doc
.block_records
.iter()
.filter(|&br| is_real_layout(br))
.map(|br| br.name.clone())
.collect();
stack.extend(
crate::scene::render_graph::document_block_uses(doc)
.into_iter()
.map(|block_use| block_use.insert.block_name),
);
while let Some(n) = stack.pop() {
if !live.insert(n.clone()) {
continue;
}
for c in children(&n) {
if !live.contains(&c) {
stack.push(c);
}
}
}
live
};
// Build removal lists (still immutable)
let layer_remove: Vec<String> = if do_layers {
self.tabs[i]
@@ -466,26 +410,21 @@ impl OpenCADStudio {
vec![]
};
// Remove unreachable blocks but retain live xrefs and space containers.
// Remove blocks nothing references any more. The shared
// service decides what qualifies — layout/xref/dynamic
// records and open BEDIT sessions are protected, and so is
// every reference kind — so a purge and a delete give the
// same answer.
let block_remove: Vec<String> = if do_blocks {
self.tabs[i]
.scene
.document
.block_records
let open_edits: Vec<codec::Handle> = self.tabs[i]
.block_edits
.iter()
.filter(|br| {
let up = br.name.to_ascii_uppercase();
// Never PURGE an xref *definition* — that is a
// detach, not a purge. Orphaned dependencies from
// a past detach still use the reachability test.
!br.flags.is_xref
&& !br.flags.is_xref_overlay
&& !up.starts_with("*MODEL_SPACE")
&& !up.starts_with("*PAPER_SPACE")
&& !live_blocks.contains(&br.name)
})
.map(|br| br.name.clone())
.collect()
.map(|session| session.br_handle)
.collect();
crate::scene::block_service::purge_candidates(
&self.tabs[i].scene.document,
&open_edits,
)
} else {
vec![]
};
@@ -501,7 +440,7 @@ impl OpenCADStudio {
// Snapshot the pre-purge document once, before the first
// removal, so a single UNDO restores everything.
if !snapshot_pushed {
self.push_undo_snapshot(i, "PURGE");
self.push_undo_snapshot(i, "Purge");
snapshot_pushed = true;
}
@@ -523,26 +462,11 @@ impl OpenCADStudio {
.count();
let mut blocks_gone = 0usize;
for name in &block_remove {
// Drop the block definition's member entities (and the
// BLOCK/ENDBLK delimiters) before the record so no orphaned
// geometry survives in the document entity list.
let handles: Vec<_> =
if let Some(br) = self.tabs[i].scene.document.block_records.get(name) {
let mut h = br.entity_handles.clone();
h.push(br.block_entity_handle);
h.push(br.block_end_handle);
h
} else {
Vec::new()
};
for h in handles {
self.tabs[i].scene.document.remove_entity(h);
}
if self.tabs[i]
.scene
.document
.block_records
.remove(name)
// The service erases the definition, its member
// entities, both BLOCK/ENDBLK delimiters and any stale
// draw-order table, so a purge and a delete leave the
// same document behind.
if crate::scene::block_service::erase(&mut self.tabs[i].scene, name)
.is_some()
{
blocks_gone += 1;
@@ -586,7 +510,7 @@ impl OpenCADStudio {
// re-assignment needed (a block purge already snapshotted;
// a snapshot here only matters when nothing else changed).
if !snapshot_pushed {
self.push_undo_snapshot(i, "PURGE");
self.push_undo_snapshot(i, "Purge");
}
for h in &orphans {
self.tabs[i].scene.document.objects.remove(h);
@@ -1287,6 +1211,8 @@ impl OpenCADStudio {
| "UOSNAP"
| "FIELDDISPLAY"
| "SURFACEASSOCIATIVITY"
| "UCSORTHO"
| "UCSVIEW"
| "PDFIMPORTMODE"
| "PDFIMPORTFILTER"
| "PDFIMPORTLAYERS"
@@ -1525,6 +1451,32 @@ impl OpenCADStudio {
}
return Some(self.finish_dispatch(cmd));
}
if matches!(name.as_str(), "UCSORTHO" | "UCSVIEW") {
use crate::modules::view::view_cmd;
let current = i16::from(if name == "UCSORTHO" { view_cmd::ucs_ortho() } else { view_cmd::ucs_view() });
match value.as_deref().map(|v| v.trim().parse::<i16>().ok().filter(|v| (0..=1).contains(v))) {
Some(Some(mode)) => {
if current != mode {
if name == "UCSORTHO" {
view_cmd::set_ucs_ortho(mode == 1);
} else {
view_cmd::set_ucs_view(mode == 1);
}
self.save_config();
}
}
Some(None) => {
self.command_line.push_error(crate::t!("Requires 0 or 1 only.").as_ref());
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name.clone());
}
None => {
self.command_line.push_output(&format!("Enter new value for {name} <{current}>:"));
self.pending_setvar = Some(name.clone());
}
}
return Some(self.finish_dispatch(cmd));
}
if name == "SURFACEASSOCIATIVITY" {
use crate::scene::model::sweep_model;
let current = i16::from(sweep_model::surface_associativity());
@@ -1805,6 +1757,18 @@ impl OpenCADStudio {
_ => unreachable!(),
};
if let Some(value) = &value {
// Web CENTERLTYPEFILE: no path to read — pick the
// .lin/.dnl file through the browser, read its
// bytes at pick time and populate the document
// from the text (native reads the typed path
// inside `set_centerline_setting`).
#[cfg(target_arch = "wasm32")]
if name == "CENTERLTYPEFILE" && !value.trim().is_empty() {
return Some(Task::perform(
crate::io::pick_linetype_file_web(),
Message::CenterlineLinetypeFilePicked,
));
}
match self.tabs[i].scene.set_centerline_setting(&name, value) {
Ok(message) => {
self.tabs[i].dirty = true;
@@ -3364,7 +3328,13 @@ impl OpenCADStudio {
} else {
// Snapshot BEFORE mutating (undo must restore the old
// name); dropped again if nothing was renamed.
self.push_undo_snapshot(i, "RENAME");
let block = type_str == "BLOCK";
let label = if block {
format!("Rename block {old_name} to {new_name}")
} else {
"RENAME".to_string()
};
self.push_undo_snapshot(i, label);
let known = matches!(
type_str.as_str(),
"LAYER"
@@ -3377,11 +3347,31 @@ impl OpenCADStudio {
| "UCS"
| "VIEW"
);
let ok = if type_str == "BLOCK" {
// The block path's refusal text comes from the shared
// service, so the command line, the palette, the plugin
// API and the control op all word it the same way.
let mut block_error: Option<String> = None;
let ok = if block {
let open_edits: Vec<codec::Handle> = self.tabs[i]
.block_edits
.iter()
.map(|session| session.br_handle)
.collect();
// Re-keys the record, syncs the Block marker and every
// INSERT reference; refuses anonymous/xref sources and
// invalid/taken names.
self.tabs[i].scene.rename_block(&old_name, &new_name)
// INSERT reference; refuses anonymous/xref sources,
// protected records and invalid/taken names.
match crate::scene::block_service::rename(
&mut self.tabs[i].scene,
&old_name,
&new_name,
&open_edits,
) {
Ok(_) => true,
Err(error) => {
block_error = Some(error.to_string());
false
}
}
} else if type_str == "LAYER" {
self.tabs[i].rename_layer(&old_name, &new_name)
} else if known {
@@ -3416,13 +3406,10 @@ impl OpenCADStudio {
);
} else {
self.discard_last_undo_entry(i);
if !known {
if let Some(message) = block_error {
self.command_line.push_error(&message);
} else if !known {
self.command_line.push_error(crate::tf!("RENAME: unknown type '{}'. Use LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW", type_str).as_ref());
} else if type_str == "BLOCK" {
self.command_line.push_error(crate::tf!(
"RENAME: could not rename block '{}' — missing/anonymous/xref, or '{}' is invalid or taken.",
old_name, new_name
).as_ref());
} else {
self.command_line.push_error(crate::tf!(
"RENAME: could not rename '{}' in {} — not found or protected, or '{}' is invalid or taken.",
+34 -2
View File
@@ -449,6 +449,7 @@ impl OpenCADStudio {
}
}
}
crate::app::shortcuts::sync_space_dispatch_flag(&self.shortcut_bindings);
self.persist_settings_if_changed();
self.command_line.push_output(
crate::tf!("CUIIMPORT: loaded {n} shortcut(s) from \"{path}\".")
@@ -483,6 +484,9 @@ impl OpenCADStudio {
self.command_line.push_error(crate::t!("Usage: SHORTCUTS SET <key> <command> e.g. SHORTCUTS SET CTRL+D DIST").as_ref());
} else {
self.shortcut_bindings.insert(key.clone(), cmd_str.clone());
crate::app::shortcuts::sync_space_dispatch_flag(
&self.shortcut_bindings,
);
self.persist_settings_if_changed();
self.command_line.push_output(
crate::tf!("Shortcut set: {key} → {cmd_str}").as_ref(),
@@ -500,6 +504,9 @@ impl OpenCADStudio {
.remove(&crate::app::shortcuts::normalize_key(&key))
.is_some()
{
crate::app::shortcuts::sync_space_dispatch_flag(
&self.shortcut_bindings,
);
self.persist_settings_if_changed();
self.command_line
.push_output(crate::tf!("Shortcut '{key}' removed.").as_ref());
@@ -528,7 +535,8 @@ impl OpenCADStudio {
"Available themes: {}",
"DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA \
SYSTEM (follows the OS or browser color scheme)",
));
self.command_line.push_info(&crate::tf!(
"Current color scheme: <{}>",
@@ -552,7 +560,8 @@ impl OpenCADStudio {
self.command_line.push_output(&crate::tf!("Available themes: {}",
"DARK LIGHT DRACULA NORD SOLARIZED_LIGHT SOLARIZED_DARK \
GRUVBOX_LIGHT GRUVBOX_DARK TOKYONIGHT TOKYONIGHTSTORM TOKYONIGHTLIGHT \
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA",
KANAGAWAWAVE KANAGAWADRAGON KANAGAWALOTUS MOONFLY NIGHTFLY OXOCARBON FERRA \
SYSTEM (follows the OS or browser color scheme)",
));
return Some(Task::none());
}
@@ -562,6 +571,29 @@ impl OpenCADStudio {
);
return Some(Task::none());
}
// SYSTEM keeps its own name in the saved config and resolves
// to a concrete Light/Dark theme at run time — the browser's
// prefers-color-scheme on the web, the Windows personalization
// setting natively. Re-running the command re-resolves; there
// is no live preference listener.
if upper_sub == "SYSTEM" {
if self.ui_theme.name == "Custom" {
self.saved_custom_palette = Some(self.ui_theme.palette);
}
let resolved = crate::app::config::resolve_system_theme();
let name = format!("{resolved:?}");
self.ui_theme.name = "System".to_string();
self.ui_theme.palette =
crate::app::config::UiThemePalette::from_iced(resolved.seed());
self.theme_color_inputs = self.ui_theme.palette.hex_values();
self.active_theme = resolved;
self.sync_model_space_theme(true);
self.persist_settings_if_changed();
self.command_line.push_output(crate::tf!(
"Color scheme set to 'System' (currently {name})."
).as_ref());
return Some(Task::none());
}
let theme = crate::app::config::parse_theme_name(sub);
if let Some(t) = theme {
if self.ui_theme.name == "Custom" {
+341
View File
@@ -0,0 +1,341 @@
//! What -VIEW carries out: the view presets (with the orthographic UCS of
//! UCSORTHO), named views saved with the UCS of UCSVIEW, and the per-view
//! settings of its sEttings option.
use super::super::{Message, OpenCADStudio};
use crate::command::CadCommand;
use crate::modules::view::view_cmd::{self, DashViewCommand, ViewInfo, ViewNames};
use codec::objects::ObjectType;
use codec::types::{Handle, Vector3};
use iced::Task;
/// The xrecord in a view's extension dictionary holding its category and
/// layer snapshot, as the reference keeps them.
const VIEW_INFO: &str = "ADSK_XREC_VTRVIEWINFO";
/// The layer state a view's layer snapshot is stored under.
fn snapshot_name(view: &str) -> String {
format!("ACAD_VIEWS_{view}")
}
impl OpenCADStudio {
/// Names -VIEW checks its input against, taken from the drawing.
pub(super) fn dash_view_names(&self, i: usize) -> ViewNames {
let document = &self.tabs[i].scene.document;
let visual_styles = crate::io::visual_styles::offered(document);
let live_sections: Vec<(String, Handle)> = document
.entities()
.filter_map(|entity| match entity {
codec::EntityType::Extended(extended) => match &extended.data {
codec::entities::ExtendedEntityData::SectionObject(section) => {
Some((section.name.clone(), extended.common.handle))
}
_ => None,
},
_ => None,
})
.collect();
let name_of = |list: &[(String, Handle)], handle: Handle| {
(!handle.is_null())
.then(|| list.iter().find(|(_, h)| *h == handle).map(|(name, _)| name.clone()))
.flatten()
};
let views = document
.views
.iter()
.map(|view| ViewInfo {
name: view.name.clone(),
category: (!view.handle.is_null())
.then(|| document.xrecord(view.handle, VIEW_INFO))
.flatten()
.and_then(|record| record.get_string(300))
.unwrap_or_default()
.to_string(),
visual_style: name_of(&visual_styles, view.visual_style_handle),
live_section: name_of(&live_sections, view.live_section_handle),
})
.collect();
ViewNames {
views,
visual_styles: visual_styles.into_iter().map(|(name, _)| name).collect(),
live_sections: live_sections.into_iter().map(|(name, _)| name).collect(),
}
}
/// Set a standard view: the world orthographic or isometric view of
/// `preset`. An orthographic one also sets its orthographic UCS while
/// UCSORTHO is on; asking for the view shown already keeps it.
pub(in crate::app) fn apply_view_preset(&mut self, preset: &str) -> Task<Message> {
use crate::scene::pipeline::viewcube::{
FACE_BACK, FACE_BOTTOM, FACE_FRONT, FACE_LEFT, FACE_RIGHT, FACE_TOP,
};
use crate::scene::CubeRegion;
let i = self.active_tab;
let region = match preset {
"TOP" => CubeRegion::Face(FACE_TOP),
"BOTTOM" => CubeRegion::Face(FACE_BOTTOM),
"FRONT" => CubeRegion::Face(FACE_FRONT),
"BACK" => CubeRegion::Face(FACE_BACK),
"LEFT" => CubeRegion::Face(FACE_LEFT),
"RIGHT" => CubeRegion::Face(FACE_RIGHT),
_ => {
let (x, y) = match preset {
"SEISO" => (1.0, -1.0),
"NEISO" => (1.0, 1.0),
"NWISO" => (-1.0, 1.0),
_ => (-1.0, -1.0),
};
let want = glam::Vec3::new(x, y, 1.0).normalize();
(18..26)
.map(CubeRegion::Corner)
.max_by(|a, b| a.snap_direction().dot(want).total_cmp(&b.snap_direction().dot(want)))
.expect("the cube has corners")
}
};
if view_cmd::ucs_ortho() && self.tabs[i].active_block_edit.is_none() {
if let Some((name, x, y, ortho)) = view_cmd::orthographic_ucs(preset) {
// The top view's UCS is the world one.
self.tabs[i].active_ucs = (ortho != 1).then(|| {
let mut ucs = codec::tables::Ucs::new(name);
ucs.origin = Vector3::ZERO;
ucs.x_axis = Vector3::new(x[0], x[1], x[2]);
ucs.y_axis = Vector3::new(y[0], y[1], y[2]);
ucs.ortho_type = ortho;
ucs
});
self.commit_active_ucs_change(i, "-VIEW");
}
}
self.command_line.push_output(crate::t!("Regenerating model.").as_ref());
self.snap_view_preset(region)
}
/// Carry out what -VIEW gathered (`-VIEW >VERB args`).
pub(super) fn run_dash_view(&mut self, rest: &str, i: usize) -> Task<Message> {
let (verb, arg) = rest.split_once(' ').unwrap_or((rest, ""));
let mut settings_again = false;
match verb {
"END" => {
if !arg.is_empty() {
self.command_line.push_output(arg);
}
}
"PRESET" => return self.apply_view_preset(arg),
"DELETE" => {
let doomed: Vec<String> = view_cmd::matching(
self.tabs[i].scene.document.views.iter().map(|view| view.name.as_str()),
arg,
)
.into_iter()
.map(str::to_string)
.collect();
if !doomed.is_empty() {
self.push_undo_snapshot(i, "-VIEW");
for name in &doomed {
self.tabs[i].scene.document.views.remove(name);
self.tabs[i].scene.document.delete_layer_state(&snapshot_name(name));
}
self.tabs[i].dirty = true;
}
}
"RESTORE" => {
let Some(view) = self.tabs[i].scene.document.views.get(arg).cloned() else {
return Task::none();
};
let before = self.tabs[i].scene.active_gaze_dir();
self.tabs[i].scene.restore_named_view(&view);
if view.ucs_associated && self.tabs[i].active_block_edit.is_none() {
let world = view.ucs_x_axis == Vector3::new(1.0, 0.0, 0.0)
&& view.ucs_y_axis == Vector3::new(0.0, 1.0, 0.0)
&& view.ucs_origin == Vector3::ZERO;
self.tabs[i].active_ucs = (!world).then(|| {
let name = view_cmd::PRESETS
.iter()
.filter_map(|preset| view_cmd::orthographic_ucs(preset))
.find(|(_, _, _, ortho)| *ortho == view.ucs_ortho_type)
.map_or("*ACTIVE*", |(name, ..)| name);
let mut ucs = codec::tables::Ucs::new(name);
ucs.origin = view.ucs_origin;
ucs.x_axis = view.ucs_x_axis;
ucs.y_axis = view.ucs_y_axis;
ucs.elevation = view.ucs_elevation;
ucs.ortho_type = view.ucs_ortho_type;
ucs
});
self.commit_active_ucs_change(i, "-VIEW");
}
if self.tabs[i].scene.active_gaze_dir().dot(before) < 0.9999 {
self.command_line.push_output(crate::t!("Regenerating model.").as_ref());
}
}
"SAVE" => {
let mut view = self.tabs[i].scene.current_as_named_view(arg);
if view_cmd::ucs_view() {
self.command_line.push_output("UCSVIEW = 1 UCS will be saved with view");
self.store_ucs_in_view(i, &mut view);
}
self.save_named_view(i, view);
}
"WINDOW" => {
let parts: Vec<&str> = arg.split('\u{1f}').collect();
let corner = |s: &str| {
let (x, y) = s.split_once(',')?;
Some((x.trim().parse::<f64>().ok()?, y.trim().parse::<f64>().ok()?))
};
match (parts.first(), parts.get(1).and_then(|s| corner(s)), parts.get(2).and_then(|s| corner(s))) {
(Some(name), Some(a), Some(b)) if a.0 != b.0 && a.1 != b.1 => {
// A plan view of the window between the corners; the
// reference keeps no UCS with a window view.
let mut view = self.tabs[i].scene.current_as_named_view(name);
view.target = Vector3 { x: (a.0 + b.0) / 2.0, y: (a.1 + b.1) / 2.0, z: 0.0 };
view.direction = Vector3 { x: 0.0, y: 0.0, z: 1.0 };
view.width = (b.0 - a.0).abs();
view.height = (b.1 - a.1).abs();
self.save_named_view(i, view);
}
_ => self.command_line.push_output("Invalid window specification."),
}
}
"SETTINGS" => settings_again = true,
"UCSVIEW" => {
view_cmd::set_ucs_view(arg == "1");
self.save_config();
settings_again = true;
}
"SET" => {
let parts: Vec<&str> = arg.split('\u{1f}').collect();
if let [setting, view, value] = parts[..] {
self.apply_view_setting(i, setting, view, value);
}
settings_again = true;
}
_ => {}
}
if settings_again {
let command = DashViewCommand::settings(self.dash_view_names(i));
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
self.finish_dispatch("-VIEW")
}
/// Keep the current UCS with a view being saved (UCSVIEW).
fn store_ucs_in_view(&self, i: usize, view: &mut codec::tables::View) {
view.ucs_associated = true;
match &self.tabs[i].active_ucs {
Some(ucs) => {
view.ucs_origin = ucs.origin;
view.ucs_x_axis = ucs.x_axis;
view.ucs_y_axis = ucs.y_axis;
view.ucs_elevation = ucs.elevation;
view.ucs_ortho_type = ucs.ortho_type;
view.named_ucs_handle = ucs.named_ucs_handle;
}
None => {
view.ucs_origin = Vector3::ZERO;
view.ucs_x_axis = Vector3::new(1.0, 0.0, 0.0);
view.ucs_y_axis = Vector3::new(0.0, 1.0, 0.0);
view.ucs_elevation = 0.0;
view.ucs_ortho_type = 0;
}
}
}
/// Add or replace a named view, keeping the record (and so its settings)
/// of a view saved again under the same name.
fn save_named_view(&mut self, i: usize, mut view: codec::tables::View) {
self.push_undo_snapshot(i, "-VIEW");
let document = &mut self.tabs[i].scene.document;
match document.views.get(&view.name).cloned() {
Some(old) => {
view.name = old.name;
view.handle = old.handle;
view.visual_style_handle = old.visual_style_handle;
view.live_section_handle = old.live_section_handle;
view.background_handle = old.background_handle;
}
None => {
view.handle = document.allocate_handle();
if view.visual_style_handle.is_null() {
view.visual_style_handle = crate::io::visual_styles::offered(document)
.into_iter()
.find(|(name, _)| name == "2D Wireframe")
.map_or(Handle::NULL, |(_, handle)| handle);
}
}
}
document.views.add_or_replace(view);
self.tabs[i].dirty = true;
}
/// One setting of -VIEW's sEttings option on the named view.
fn apply_view_setting(&mut self, i: usize, setting: &str, name: &str, value: &str) {
let Some(mut view) = self.tabs[i].scene.document.views.get(name).cloned() else {
return;
};
self.push_undo_snapshot(i, "-VIEW");
let document = &mut self.tabs[i].scene.document;
if view.handle.is_null() {
view.handle = document.allocate_handle();
}
let find = |list: Vec<(String, Handle)>| {
list.into_iter().find(|(entry, _)| entry.eq_ignore_ascii_case(value)).map_or(Handle::NULL, |(_, h)| h)
};
match setting {
"BACKGROUND" => view.background_handle = Handle::NULL,
"VISUALSTYLE" => view.visual_style_handle = find(crate::io::visual_styles::offered(document)),
"SECTION" => {
let sections: Vec<(String, Handle)> = document
.entities()
.filter_map(|entity| match entity {
codec::EntityType::Extended(extended) => match &extended.data {
codec::entities::ExtendedEntityData::SectionObject(section) => {
Some((section.name.clone(), extended.common.handle))
}
_ => None,
},
_ => None,
})
.collect();
view.live_section_handle = find(sections);
}
"CATEGORIZE" | "LAYER" => {
let snapshot = snapshot_name(&view.name);
let layer_name = match (setting, value) {
("LAYER", "SAVE") => {
document.capture_layer_state(snapshot.clone(), "");
Some(snapshot)
}
("LAYER", _) => {
document.delete_layer_state(&snapshot);
Some(String::new())
}
_ => None,
};
let record = document.ensure_xrecord(view.handle, VIEW_INFO);
if let Some(ObjectType::XRecord(record)) = document.objects.get_mut(&record) {
let text = |record: &codec::objects::XRecord, code: i32| {
record.get_string(code).unwrap_or_default().to_string()
};
let category = if setting == "CATEGORIZE" { value.to_string() } else { text(record, 300) };
let layers = layer_name.unwrap_or_else(|| text(record, 302));
let mut rest: Vec<_> =
record.entries.iter().filter(|entry| !matches!(entry.code, 300 | 302)).cloned().collect();
if rest.is_empty() {
rest = vec![
codec::objects::XRecordEntry::bool(293, false),
codec::objects::XRecordEntry::bool(294, false),
];
}
record.entries.clear();
record.add_string(300, category);
record.add_string(302, layers);
record.entries.extend(rest);
}
}
_ => {}
}
document.views.add_or_replace(view);
self.tabs[i].dirty = true;
}
}
+11 -3
View File
@@ -6,13 +6,14 @@ use crate::scene::pick::xclip as clip;
impl OpenCADStudio {
/// A block reference's extents in WCS: its block's objects through the
/// insert transform.
/// insert transform — the base-point-aware one, because that is where the
/// renderer puts the contents the user drew the boundary around.
fn insert_extents(&self, i: usize, insert: codec::Handle) -> ([f64; 2], [f64; 2]) {
let document = &self.tabs[i].scene.document;
let Some(codec::EntityType::Insert(ins)) = document.get_entity(insert) else {
return ([0.0; 2], [0.0; 2]);
};
let xform = ins.get_transform();
let xform = crate::scene::render_graph::insert_transform(document, ins);
let (mut lo, mut hi) = ([f64::MAX; 2], [f64::MIN; 2]);
for e in document.entities_in_block(&ins.block_name) {
let (a, b) = crate::scene::convert::tess::entity_bounds_in(document, e);
@@ -77,7 +78,14 @@ impl OpenCADStudio {
else {
continue;
};
let outline = clip::clip_outline_world(doc, filter, &ins.get_transform());
// The same base-point-aware transform the spatial filter
// was stored with, so the generated polyline lands on the
// boundary the user drew (the two cancel exactly).
let outline = clip::clip_outline_world(
doc,
filter,
&crate::scene::render_graph::insert_transform(doc, ins),
);
let mut pl = codec::entities::LwPolyline::from_points(
outline
.iter()
+103
View File
@@ -30,6 +30,11 @@ pub struct AppConfig {
/// General edge-stack dock layout (which panels are docked, side, order,
/// width and auto-collapse) for the Properties panel and block palette.
pub dock: crate::ui::dock::DockState,
/// Whether the optional Graphic Attributes palette is visible.
pub show_graphic_attributes: bool,
/// Graphic Attributes creates fills on the current layer instead of the
/// layer of the filled object.
pub graphic_fills_on_current_layer: bool,
/// Add a newly selected annotation scale to existing annotative objects.
pub annotation_auto_scale: i8,
/// Ribbon collapse density.
@@ -52,6 +57,8 @@ impl Default for AppConfig {
start: StartConfig::default(),
statusbar: StatusBarConfig::default(),
dock: crate::ui::dock::DockState::default(),
show_graphic_attributes: false,
graphic_fills_on_current_layer: false,
annotation_auto_scale: -4,
ribbon: RibbonConfig::default(),
plot: PlotDialogState::default(),
@@ -102,12 +109,77 @@ impl UiThemeConfig {
pub fn to_iced(&self) -> iced::Theme {
if self.name == "Custom" {
iced::Theme::custom("Custom", self.palette.to_iced())
} else if self.name.eq_ignore_ascii_case("System") {
resolve_system_theme()
} else {
builtin_theme(&self.name).unwrap_or(iced::Theme::Oxocarbon)
}
}
}
/// Resolve the `System` color scheme (COLORSCHEME SYSTEM) to a concrete
/// theme: the browser `prefers-color-scheme` media query on the web, the
/// Windows `AppsUseLightTheme` personalization value natively, and the dark
/// default on the remaining native platforms (no cheap OS query there).
/// Read at boot (`apply_config`) and re-read whenever COLORSCHEME SYSTEM
/// runs again — there is no live preference listener by design.
pub fn resolve_system_theme() -> iced::Theme {
#[cfg(target_arch = "wasm32")]
{
let light = web_sys::window().is_some_and(|window| {
window
.match_media("(prefers-color-scheme: light)")
.ok()
.flatten()
.is_some_and(|query| query.matches())
});
if light {
iced::Theme::Light
} else {
iced::Theme::Dark
}
}
#[cfg(all(not(target_arch = "wasm32"), target_os = "windows"))]
{
use windows_sys::Win32::System::Registry::{
RegGetValueW, HKEY_CURRENT_USER, RRF_RT_REG_DWORD,
};
let sub_key: Vec<u16> = "Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let value: Vec<u16> = "AppsUseLightTheme"
.encode_utf16()
.chain(std::iter::once(0))
.collect();
let mut data: u32 = 0;
let mut size = std::mem::size_of::<u32>() as u32;
// ERROR_SUCCESS (0) with data 1 means the apps theme is Light; a
// missing value (older Windows, wiped personalization) is Dark.
let light = unsafe {
RegGetValueW(
HKEY_CURRENT_USER,
sub_key.as_ptr(),
value.as_ptr(),
RRF_RT_REG_DWORD,
std::ptr::null_mut(),
&mut data as *mut u32 as *mut core::ffi::c_void,
&mut size,
) == 0
&& data == 1
};
if light {
iced::Theme::Light
} else {
iced::Theme::Dark
}
}
#[cfg(not(any(target_arch = "wasm32", target_os = "windows")))]
{
iced::Theme::Dark
}
}
#[derive(Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct UiThemePalette {
@@ -531,6 +603,25 @@ fn config_path() -> Option<std::path::PathBuf> {
mod tests {
use super::*;
#[test]
fn system_scheme_resolves_to_a_concrete_theme_and_keeps_its_name() {
// Fresh configs keep the existing default; SYSTEM is opt-in.
assert_ne!(UiThemeConfig::default().name, "System");
let mut cfg = UiThemeConfig::default();
cfg.name = "System".to_string();
let resolved = cfg.to_iced();
// Case-insensitive like the other stored names.
cfg.name = "system".to_string();
assert_eq!(cfg.to_iced(), resolved);
assert!(
matches!(resolved, iced::Theme::Light | iced::Theme::Dark),
"SYSTEM must resolve through the platform preference"
);
// The stored name survives resolution; re-runs re-resolve.
assert_eq!(cfg.name, "system");
assert_eq!(resolve_system_theme(), resolved);
}
#[test]
fn test_model_space_defaults() {
let cfg = ModelSpaceThemeConfig::default();
@@ -612,6 +703,18 @@ mod tests {
assert_eq!(deserialized.model_space.selection_window_color, 5);
}
#[test]
fn graphic_attributes_visibility_defaults_off_and_round_trips() {
let missing: AppConfig = serde_json::from_str("{}").expect("deserialize old config");
assert!(!missing.show_graphic_attributes);
let mut original = AppConfig::default();
original.show_graphic_attributes = true;
let serialized = serde_json::to_string(&original).expect("serialize config");
let restored: AppConfig = serde_json::from_str(&serialized).expect("deserialize config");
assert!(restored.show_graphic_attributes);
}
#[test]
fn test_parse_theme_name() {
assert_eq!(parse_theme_name("light"), Some(iced::Theme::Light));
+64 -17
View File
@@ -92,6 +92,16 @@ pub(crate) fn property_json(p: &crate::scene::model::object::Property) -> Value
}
pub(super) const NAMES: &[&str] = &[
"close_modal",
// Native-only: the "changed outside" dialog does not exist in the web
// build, so neither does its action set.
#[cfg(not(target_arch = "wasm32"))]
"external_change_reload",
#[cfg(not(target_arch = "wasm32"))]
"external_change_overwrite",
#[cfg(not(target_arch = "wasm32"))]
"external_change_save_as",
#[cfg(not(target_arch = "wasm32"))]
"external_change_cancel",
"pdf_dialog_ok",
"pdf_layer_toggle",
"pc_manager_toggle",
@@ -500,8 +510,11 @@ impl OpenCADStudio {
use crate::io::pdf_export::PdfPageInput;
let path = string(req, "path")?;
if !path.to_ascii_lowercase().ends_with(".pdf") {
return Err(failure("invalid_path", "plot writes .pdf files"));
let lower = path.to_ascii_lowercase();
// SVG shares the plot page with PDF: the writer follows the extension.
let is_svg = lower.ends_with(".svg");
if !is_svg && !lower.ends_with(".pdf") {
return Err(failure("invalid_path", "plot writes .pdf or .svg files"));
}
self.stamp_plot_fields();
let i = self.active_tab;
@@ -730,9 +743,16 @@ impl OpenCADStudio {
self.tabs[i].scene.camera_generation = original_camera_generation;
let pages = built.map_err(|e| failure("plot_failed", e))?;
if req["per_page"].as_bool().unwrap_or(false) {
// One PDF per layout: <stem>-<Layout>.pdf beside the requested
// One file per layout: <stem>-<Layout>.pdf/.svg beside the requested
// path (sheet-by-sheet delivery).
let stem = path.strip_suffix(".pdf").unwrap_or(path).to_owned();
let stem = path
.strip_suffix(".pdf")
.or_else(|| path.strip_suffix(".PDF"))
.or_else(|| path.strip_suffix(".svg"))
.or_else(|| path.strip_suffix(".SVG"))
.unwrap_or(path)
.to_owned();
let ext = if is_svg { "svg" } else { "pdf" };
let mut files = Vec::with_capacity(pages.len());
for (name, page) in targets.iter().zip(&pages) {
let safe: String = name
@@ -745,13 +765,21 @@ impl OpenCADStudio {
}
})
.collect();
let file = format!("{stem}-{safe}.pdf");
crate::io::pdf_export::export_pdf_pages(
std::slice::from_ref(page),
std::path::Path::new(&file),
loaded_style.as_ref(),
)
.map_err(|e| failure("plot_failed", e))?;
let file = format!("{stem}-{safe}.{ext}");
let result = if is_svg {
crate::io::svg_export::export_svg_pages(
std::slice::from_ref(page),
std::path::Path::new(&file),
loaded_style.as_ref(),
)
} else {
crate::io::pdf_export::export_pdf_pages(
std::slice::from_ref(page),
std::path::Path::new(&file),
loaded_style.as_ref(),
)
};
result.map_err(|e| failure("plot_failed", e))?;
files.push(json!({"layout": name, "path": file}));
}
self.set_control_result(json!({ "files": files, "pages": pages.len() }));
@@ -760,12 +788,20 @@ impl OpenCADStudio {
// Pages may drop the style when a stored page setup overwrites the
// dialog's style fields, so the explicit request style rides along as
// the export-level fallback.
crate::io::pdf_export::export_pdf_pages(
&pages,
std::path::Path::new(path),
loaded_style.as_ref(),
)
.map_err(|e| failure("plot_failed", e))?;
let result = if is_svg {
crate::io::svg_export::export_svg_pages(
&pages,
std::path::Path::new(path),
loaded_style.as_ref(),
)
} else {
crate::io::pdf_export::export_pdf_pages(
&pages,
std::path::Path::new(path),
loaded_style.as_ref(),
)
};
result.map_err(|e| failure("plot_failed", e))?;
self.set_control_result(json!({
"path": path,
"pages": pages.len(),
@@ -1023,6 +1059,17 @@ impl OpenCADStudio {
let name = string(req, "name")?;
let msg = match name {
"close_modal" => Message::CloseModal,
// The "changed outside" modal's suggested actions (D6): reload
// the disk copy, keep the in-memory one and overwrite, save as,
// or dismiss. Native only — the web build has no such dialog.
#[cfg(not(target_arch = "wasm32"))]
"external_change_reload" => Message::ExternalChangeReload,
#[cfg(not(target_arch = "wasm32"))]
"external_change_overwrite" => Message::ExternalChangeOverwrite,
#[cfg(not(target_arch = "wasm32"))]
"external_change_save_as" => Message::ExternalChangeSaveAs,
#[cfg(not(target_arch = "wasm32"))]
"external_change_cancel" => Message::ExternalChangeCancel,
// The open PDF dialog's OK button.
// Underlay Layers: switch a layer of the shown underlay.
"pdf_layer_toggle" => {
+343 -58
View File
@@ -8,29 +8,158 @@ use crate::command::EntityTransform;
type Parsed<T> = Result<T, Value>;
/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`.
fn point_field(req: &Value, key: &str) -> Parsed<codec::types::Vector3> {
let values = req[key]
.as_array()
/// Parse `[x,y]` or `[x,y,z]` without silently turning a malformed
/// coordinate into zero.
fn point_value(value: Option<&Value>, label: &str, code: &str) -> Parsed<codec::types::Vector3> {
let values = value
.and_then(Value::as_array)
.filter(|v| (2..=3).contains(&v.len()))
.ok_or_else(|| failure("invalid_point", format!("{key} must be [x,y] or [x,y,z]")))?;
.ok_or_else(|| failure(code, format!("{label} must be [x,y] or [x,y,z]")))?;
let coord = |i: usize| {
values[i]
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure("invalid_point", format!("{key} needs finite coordinates")))
.ok_or_else(|| failure(code, format!("{label} needs finite coordinates")))
};
Ok(codec::types::Vector3::new(
coord(0)?,
coord(1)?,
values.get(2).and_then(Value::as_f64).unwrap_or(0.0),
))
let z = values
.get(2)
.map(|value| {
value
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure(code, format!("{label} needs finite coordinates")))
})
.transpose()?
.unwrap_or(0.0);
Ok(codec::types::Vector3::new(coord(0)?, coord(1)?, z))
}
/// `[x,y]` or `[x,y,z]` (missing z = 0) from `req[key]`.
fn point_field(req: &Value, key: &str) -> Parsed<codec::types::Vector3> {
point_value(req.get(key), key, "invalid_point")
}
fn point_list_field(
spec: &Value,
key: &str,
minimum: usize,
) -> Parsed<Vec<codec::types::Vector3>> {
let points = spec[key]
.as_array()
.filter(|points| points.len() >= minimum)
.ok_or_else(|| {
failure(
"invalid_spline_points",
format!("{key} needs at least {minimum} points"),
)
})?;
points
.iter()
.enumerate()
.map(|(index, point)| {
point_value(
Some(point),
&format!("{key}[{index}]"),
"invalid_spline_points",
)
})
.collect()
}
fn finite_number_list_field(spec: &Value, key: &str) -> Parsed<Vec<f64>> {
let values = spec[key]
.as_array()
.filter(|values| !values.is_empty())
.ok_or_else(|| failure("invalid_spline_values", format!("{key} needs a non-empty array")))?;
values
.iter()
.enumerate()
.map(|(index, value)| {
value
.as_f64()
.filter(|value| value.is_finite())
.ok_or_else(|| {
failure(
"invalid_spline_values",
format!("{key}[{index}] must be finite"),
)
})
})
.collect()
}
fn deg_field(req: &Value, key: &str) -> f64 {
req[key].as_f64().unwrap_or(0.0).to_radians()
}
/// Reject fields that the typed builder would otherwise silently ignore.
fn validate_entity_fields(spec: &Value, kind: &str) -> Parsed<()> {
let Some(object) = spec.as_object() else {
return Ok(());
};
for field in object.keys() {
let allowed = match kind {
"LINE" => matches!(
field.as_str(),
"type" | "start" | "end" | "thickness" | "layer" | "color"
),
"CIRCLE" => matches!(field.as_str(), "type" | "center" | "radius" | "layer" | "color"),
"ARC" => matches!(
field.as_str(),
"type" | "center" | "radius" | "start_angle_deg" | "end_angle_deg" | "layer" | "color"
),
"SPLINE" => matches!(
field.as_str(),
"type" | "degree" | "knots" | "control_points" | "fit_points" | "weights"
| "closed" | "periodic" | "rational" | "planar" | "linear" | "layer" | "color"
),
"LWPOLYLINE" | "POLYLINE" => matches!(
field.as_str(),
"type" | "vertices" | "closed" | "constant_width" | "layer" | "color"
),
"POINT" => matches!(field.as_str(), "type" | "location" | "layer" | "color"),
"TEXT" => matches!(
field.as_str(),
"type" | "value" | "position" | "height" | "rotation_deg" | "style" | "layer" | "color"
),
"MTEXT" => matches!(
field.as_str(),
"type" | "value" | "position" | "height" | "width" | "rotation_deg" | "style" | "layer" | "color"
),
"INSERT" => matches!(
field.as_str(),
"type" | "block" | "position" | "scale" | "rotation_deg" | "layer" | "color"
),
"SOLID" => matches!(field.as_str(), "type" | "corners" | "layer" | "color"),
"HATCH" => matches!(
field.as_str(),
"type" | "boundary" | "solid" | "pattern" | "pattern_scale" | "pattern_angle_deg" | "layer" | "color"
),
_ => true,
};
if !allowed {
return Err(failure(
"unknown_entity_field",
format!("Unknown field '{field}' for {kind} entity"),
));
}
}
Ok(())
}
/// Required finite angle in degrees, converted to radians without a default.
fn finite_angle_field(spec: &Value, key: &str) -> Parsed<f64> {
let degrees = spec[key]
.as_f64()
.filter(|value| value.is_finite())
.ok_or_else(|| failure("invalid_angle", format!("arc needs a finite {key}")))?;
let radians = degrees.to_radians();
radians
.is_finite()
.then_some(radians)
.ok_or_else(|| failure("invalid_angle", format!("arc needs a finite {key}")))
}
pub(super) fn hex_handles(req: &Value, key: &str) -> Parsed<Vec<codec::Handle>> {
let list = req[key]
.as_array()
@@ -99,12 +228,28 @@ fn apply_common_properties(spec: &Value, entity: &mut codec::EntityType) -> Pars
Ok(())
}
/// An explicit text style must exist in the current document; otherwise a
/// typo would silently fall back to the default style.
fn text_style_field(spec: &Value, document: &codec::CadDocument) -> Parsed<Option<String>> {
let Some(style) = spec["style"].as_str().filter(|s| !s.is_empty()) else {
return Ok(None);
};
if !document.text_styles.contains(style) {
return Err(failure(
"invalid_text_style",
format!("Text style '{style}' does not exist in the document"),
));
}
Ok(Some(style.to_owned()))
}
/// One `entities` array entry → a real entity. Geometry validation happens
/// here so a bad definition aborts the whole batch before anything commits.
fn build_entity(spec: &Value) -> Parsed<codec::EntityType> {
fn build_entity(spec: &Value, document: &codec::CadDocument) -> Parsed<codec::EntityType> {
use codec::entities::*;
use codec::types::Vector3;
let kind = spec["type"].as_str().unwrap_or("").to_ascii_uppercase();
validate_entity_fields(spec, &kind)?;
let entity = match kind.as_str() {
"LINE" => {
let mut line = Line::from_points(point_field(spec, "start")?, point_field(spec, "end")?);
@@ -125,12 +270,117 @@ fn build_entity(spec: &Value) -> Parsed<codec::EntityType> {
.as_f64()
.filter(|r| r.is_finite() && *r > 0.0)
.ok_or_else(|| failure("invalid_radius", "arc needs a finite radius > 0"))?;
EntityType::Arc(Arc::from_center_radius_angles(
let arc = Arc::from_center_radius_angles(
point_field(spec, "center")?,
radius,
deg_field(spec, "start_angle_deg"),
deg_field(spec, "end_angle_deg"),
))
finite_angle_field(spec, "start_angle_deg")?,
finite_angle_field(spec, "end_angle_deg")?,
);
if !arc.sweep_angle().is_finite() || arc.sweep_angle() <= 0.0 {
return Err(failure(
"invalid_sweep",
"arc start_angle_deg and end_angle_deg must define a finite non-zero sweep",
));
}
EntityType::Arc(arc)
}
"SPLINE" => {
let control_points = if spec.get("control_points").is_some() {
point_list_field(spec, "control_points", 2)?
} else {
Vec::new()
};
let fit_points = if spec.get("fit_points").is_some() {
point_list_field(spec, "fit_points", 2)?
} else {
Vec::new()
};
if control_points.is_empty() && fit_points.is_empty() {
return Err(failure(
"invalid_spline_points",
"spline needs control_points or fit_points (at least 2 points)",
));
}
let degree = match spec.get("degree") {
None | Some(Value::Null) => 3,
Some(value) => value
.as_i64()
.filter(|degree| (1..=10).contains(degree))
.ok_or_else(|| {
failure("invalid_spline_values", "degree must be an integer 1..10")
})? as i32,
};
let count = control_points.len();
if count > 0 && count <= degree as usize {
return Err(failure(
"invalid_spline_values",
format!("a degree {degree} spline needs more than {degree} control_points"),
));
}
let mut spline = if control_points.is_empty() {
let mut spline = Spline::from_fit_points(fit_points.clone());
spline.degree = degree;
spline
} else {
Spline::from_control_points(degree, control_points)
};
if !fit_points.is_empty() {
spline.fit_points = fit_points;
}
if spec.get("knots").is_some() {
if spline.control_points.is_empty() {
return Err(failure(
"invalid_spline_values",
"spline knots require control_points",
));
}
let knots = finite_number_list_field(spec, "knots")?;
if knots.len() != count + degree as usize + 1
|| knots.windows(2).any(|pair| pair[1] < pair[0])
{
return Err(failure(
"invalid_spline_values",
format!(
"knots must be {} non-decreasing values (control_points + degree + 1)",
count + degree as usize + 1
),
));
}
spline.knots = knots;
}
if spec.get("weights").is_some() {
if spline.control_points.is_empty() {
return Err(failure(
"invalid_spline_values",
"spline weights require control_points",
));
}
let weights = finite_number_list_field(spec, "weights")?;
if weights.len() != count || weights.iter().any(|weight| *weight <= 0.0) {
return Err(failure(
"invalid_spline_values",
format!("weights must be {count} positive values, one per control point"),
));
}
spline.weights = weights;
spline.flags.rational = true;
}
if let Some(value) = spec["closed"].as_bool() {
spline.flags.closed = value;
}
if let Some(value) = spec["periodic"].as_bool() {
spline.flags.periodic = value;
}
if let Some(value) = spec["rational"].as_bool() {
spline.flags.rational = value;
}
if let Some(value) = spec["planar"].as_bool() {
spline.flags.planar = value;
}
if let Some(value) = spec["linear"].as_bool() {
spline.flags.linear = value;
}
EntityType::Spline(spline)
}
"LWPOLYLINE" | "POLYLINE" => {
let vertices = spec["vertices"]
@@ -164,8 +414,8 @@ fn build_entity(spec: &Value) -> Parsed<codec::EntityType> {
let mut text = Text::with_value(value, point_field(spec, "position")?)
.with_height(spec["height"].as_f64().unwrap_or(2.5).abs().max(1e-6))
.with_rotation(deg_field(spec, "rotation_deg"));
if let Some(style) = spec["style"].as_str().filter(|s| !s.is_empty()) {
text.style = style.to_owned();
if let Some(style) = text_style_field(spec, document)? {
text.style = style;
}
EntityType::Text(text)
}
@@ -180,6 +430,9 @@ fn build_entity(spec: &Value) -> Parsed<codec::EntityType> {
mtext.rectangle_width = width;
}
mtext.rotation = deg_field(spec, "rotation_deg");
if let Some(style) = text_style_field(spec, document)? {
mtext.style = style;
}
EntityType::MText(mtext)
}
"INSERT" => {
@@ -209,25 +462,33 @@ fn build_entity(spec: &Value) -> Parsed<codec::EntityType> {
EntityType::Insert(insert)
}
"SOLID" => {
let corners = spec["corners"]
let raw_corners = spec["corners"]
.as_array()
.filter(|v| (3..=4).contains(&v.len()))
.ok_or_else(|| failure("invalid_corners", "solid needs 3 or 4 corners"))?;
let corner = |i: usize| -> Parsed<Vector3> {
let raw = &corners[i];
let coord = |k: usize| {
raw.get(k)
.and_then(Value::as_f64)
.filter(|v| v.is_finite())
.unwrap_or(0.0)
};
Ok(Vector3::new(coord(0), coord(1), coord(2)))
let corners: Vec<Vector3> = raw_corners
.iter()
.enumerate()
.map(|(index, value)| {
if value.as_array().is_none_or(|values| values.len() != 3) {
return Err(failure(
"invalid_corners",
format!("corners[{index}] must be [x,y,z]"),
));
}
point_value(
Some(value),
&format!("corners[{index}]"),
"invalid_corners",
)
})
.collect::<Parsed<_>>()?;
let solid = match corners.as_slice() {
[first, second, third] => Solid::triangle(*first, *second, *third),
[first, second, third, fourth] => Solid::new(*first, *second, *third, *fourth),
_ => unreachable!("corner count was validated above"),
};
let fourth = corners
.get(3)
.map(|_| corner(3))
.unwrap_or_else(|| Ok(Vector3::ZERO));
EntityType::Solid(Solid::new(corner(0)?, corner(1)?, corner(2)?, fourth?))
EntityType::Solid(solid)
}
"HATCH" => {
let boundary = spec["boundary"]
@@ -264,7 +525,7 @@ fn build_entity(spec: &Value) -> Parsed<codec::EntityType> {
return Err(failure(
"unknown_entity_type",
format!(
"Unknown entity type '{other}'. Supported: Line, Circle, Arc, LwPolyline, Point, Text, MText, Insert, Solid, Hatch"
"Unknown entity type '{other}'. Supported: Line, Circle, Arc, Spline, LwPolyline, Point, Text, MText, Insert, Solid, Hatch"
),
))
}
@@ -283,7 +544,12 @@ impl OpenCADStudio {
.as_array()
.filter(|v| !v.is_empty())
.ok_or_else(|| failure("entities_required", "Supply entities:[{type:…},…]"))?;
let built: Vec<codec::EntityType> = list.iter().map(build_entity).collect::<Parsed<_>>()?;
let built: Vec<codec::EntityType> = {
let document = &self.tabs[self.active_tab].scene.document;
list.iter()
.map(|spec| build_entity(spec, document))
.collect::<Parsed<_>>()?
};
let i = self.active_tab;
self.push_undo_snapshot(i, "ENTITIESCREATE");
@@ -633,33 +899,37 @@ impl OpenCADStudio {
Ok(Task::none())
}
/// `block_delete` — remove a block definition together with its child
/// entities, the Block/BlockEnd markers and every Insert referencing it.
/// `block_delete` — remove a block definition. A definition something
/// still points at is refused unless the request carries `"force": true`,
/// which erases the referencing INSERTs in the same undo step.
pub(super) fn control_block_delete(&mut self, req: &Value) -> Result<Task<Message>, Value> {
let name = string(req, "name")?.to_owned();
if name.starts_with('*') {
return Err(failure(
"block_protected",
"Layout block records cannot be deleted",
));
}
let force = req["force"].as_bool().unwrap_or(false);
let i = self.active_tab;
if self.tabs[i]
.scene
.document
.block_records
.get(&name)
.is_none()
{
return Err(failure(
"block_missing",
format!("Block '{name}' does not exist"),
));
}
self.push_undo_snapshot(i, "BLOCKDELETE");
let erased = erase_block_definition(&mut self.tabs[i].scene, &name);
let open_edits: Vec<codec::Handle> = self.tabs[i]
.block_edits
.iter()
.map(|session| session.br_handle)
.collect();
self.push_undo_snapshot(i, format!("Delete block {name}"));
let options = crate::scene::block_service::DeleteOptions {
force,
open_edits: &open_edits,
};
let outcome =
match crate::scene::block_service::delete(&mut self.tabs[i].scene, &name, options) {
Ok(outcome) => outcome,
Err(error) => {
self.discard_last_undo_entry(i);
return Err(failure(block_error_code(&error), error.to_string()));
}
};
self.post_ref_op(i);
self.set_control_result(json!({ "block": name, "erased": erased }));
self.set_control_result(json!({
"block": outcome.name,
"erased": outcome.erased,
"references": outcome.references.len(),
}));
Ok(Task::none())
}
@@ -722,6 +992,21 @@ pub(crate) fn erase_block_definition(scene: &mut crate::scene::Scene, name: &str
victims.len()
}
/// The automation error code for each [`BlockServiceError`], so a headless
/// client branches on a stable machine-readable code while the text stays the
/// one the shared formatter produced.
fn block_error_code(error: &crate::scene::block_service::BlockServiceError) -> &'static str {
use crate::scene::block_service::BlockServiceError as E;
match error {
E::NotFound(_) => "block_missing",
E::InvalidName(_) => "block_invalid_name",
E::NameExists(_) => "block_exists",
E::Referenced { .. } => "block_referenced",
E::InEditSession(_) => "block_in_edit",
E::Protected(_) => "block_protected",
}
}
/// `data:[{code,value},…]` → typed XData values; an absent or empty list
/// means "remove this application's record".
fn parse_xdata_values(req: &Value) -> Parsed<Option<Vec<codec::xdata::XDataValue>>> {
+431 -20
View File
@@ -106,8 +106,15 @@ pub(super) struct State {
completed: VecDeque<(String, Value, Value)>,
owner: Option<(u64, String)>,
pub enabled: bool,
/// Set by the first control request of the session (or by a web host's
/// boot-time opt-in): while control is enabled, startup prompts that
/// would block automation — the donation prompt — are suppressed (#1349).
pub automation_seen: bool,
serial: u64,
events: VecDeque<Value>,
/// Monotonic counter bumped on every real selection change, so clients
/// can detect highlight changes without polling the full selection.
selection_revision: u64,
pub(super) routing: bool,
/// Session-scoped named handle sets for selection_set_save/load.
selection_sets: std::collections::BTreeMap<String, Vec<codec::Handle>>,
@@ -117,6 +124,19 @@ pub(super) struct State {
/// Live `getpoint` request — the client asked for one picked point; Some
/// until the person clicks (answer) or presses Escape (cancel).
pub(super) get_point: Option<UserPointSession>,
/// `capture` request parked until the window has drawn another frame:
/// screenshotting in the same batch as the state change used to return the
/// previous frame (C11). Cleared by `on_control_capture_frame`.
pub(super) capture_frame_pending: bool,
/// Whether the parked capture actually waited for a redraw (vs. the
/// anti-hang timer). Reported in the capture result as `waited_for_frame`.
pub(super) capture_after_redraw: bool,
/// Web: when a parked capture gives up. Only a presented frame can be
/// read back on wasm, and a hidden or idle tab may never present one, so
/// the timer tick fails the capture past this instant instead of letting
/// it hold the control slot forever.
#[cfg(target_arch = "wasm32")]
pub(super) capture_deadline: Option<iced::time::Instant>,
}
/// The pending half of an interactive `user_select` operation (`interactive`
@@ -423,6 +443,35 @@ impl OpenCADStudio {
self.control.pending.is_some()
}
/// Mirror a real selection change into the automation event stream. The
/// plugin host already publishes the V4 selection event; automation clients
/// need the same signal instead of polling `state`.
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn control_record_selection_event(&mut self, handles: &[codec::Handle]) {
let (document_id, revision) = {
let tab = &self.tabs[self.active_tab];
(tab.id, tab.edit_revision)
};
let selection: Vec<String> = handles
.iter()
.map(|handle| format!("{:X}", handle.value()))
.collect();
self.control.selection_revision = self.control.selection_revision.wrapping_add(1);
let selection_revision = self.control.selection_revision;
self.control.serial += 1;
self.control.events.push_back(json!({
"sequence": self.control.serial,
"kind": "selection",
"document_id": document_id,
"revision": revision,
"selection_revision": selection_revision,
"selection": selection,
}));
while self.control.events.len() > 128 {
self.control.events.pop_front();
}
}
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);
@@ -435,6 +484,7 @@ impl OpenCADStudio {
"plugins":plugin_ids(),
"documents":self.tabs.iter().map(|t|json!({"id":t.id,"title":t.tab_title,"path":t.current_path,"dirty":t.dirty,"revision":t.edit_revision,"start":t.is_start})).collect::<Vec<_>>(),
"selection":tab.scene.selected_handles_in_order().iter().map(|h|format!("{:X}",h.value())).collect::<Vec<_>>(),
"selection_revision":self.control.selection_revision,
"command":command,"modal":self.active_modal.as_ref().map(|m|format!("{m:?}")),
"layout":tab.scene.current_layout,
"ucs":tab.active_ucs.as_ref().map(|u|json!({"name":u.name,"origin":[u.origin.x,u.origin.y,u.origin.z],"x_axis":[u.x_axis.x,u.x_axis.y,u.x_axis.z],"y_axis":[u.y_axis.x,u.y_axis.y,u.y_axis.z],"elevation":u.elevation})),
@@ -449,6 +499,18 @@ impl OpenCADStudio {
}
pub(super) fn control_request(&mut self, req: Value) -> (Value, Task<Message>) {
// First control contact marks the session automated (#1349): a
// DonationPrompt that is still queued (not yet shown) is dropped so
// automation never waits behind it, and `queue_startup_prompts`
// stops queueing one. A prompt already on screen stays — a human
// may be reading it; clients close it with `close_modal`.
if !self.control.automation_seen && self.control.enabled {
self.control.automation_seen = true;
if self.active_modal != Some(super::ModalKind::DonationPrompt) {
self.pending_startup_modals
.retain(|kind| kind != &super::ModalKind::DonationPrompt);
}
}
let op = req["op"].as_str().unwrap_or("");
let query = matches!(
op,
@@ -628,15 +690,32 @@ impl OpenCADStudio {
if old == &req {
result.clone()
} else {
failure("request_id_reused", "Request payload changed")
failure(
"request_id_reused",
format!(
"Request payload changed for request_id '{id}'; generate a new unique request_id or resend the original payload",
),
)
},
Task::none(),
);
}
if let Some(p) = &self.control.pending {
if p.id == id && p.request == req {
// `cancel` is a reference to the pending request, not a
// replay of its payload. It must reach the interactive cancel
// branch below even when it uses the same request_id.
if p.id == id && op != "cancel" {
return (
json!({"ok":true,"status":"running","request_id":id}),
if p.request == req {
json!({"ok":true,"status":"running","request_id":id})
} else {
failure(
"request_id_reused",
format!(
"Request payload changed for request_id '{id}'; generate a new unique request_id or resend the original payload",
),
)
},
Task::none(),
);
}
@@ -838,7 +917,11 @@ impl OpenCADStudio {
// Interactive requests hold their slot open on purpose: the
// counter stays at one so settle keeps returning `running` until
// the person at the screen answers and the resolver zeroes it.
if !matches!(p.request["op"].as_str(), Some("user_select" | "getpoint")) {
// `capture` is parked the same way until the next drawn frame.
if !matches!(
p.request["op"].as_str(),
Some("user_select" | "getpoint" | "capture")
) {
p.pending -= 1;
}
}
@@ -1071,10 +1154,9 @@ impl OpenCADStudio {
Task::none()
}
"capture" => {
let window = self
.main_window
self.main_window
.ok_or_else(|| failure("gui_required", "Capture requires a GUI window"))?;
let path = string(req, "path")?.to_owned();
string(req, "path")?;
let was_minimized = crate::sys::restore_window_if_minimized();
if was_minimized {
std::thread::sleep(std::time::Duration::from_millis(60));
@@ -1132,17 +1214,16 @@ impl OpenCADStudio {
}
}
// A minimized window has a 0x0 surface and the renderer
// panics reading it back, so report instead of capturing.
iced::window::size(window).then(move |size| {
let path = path.clone();
if size.width <= 0.0 || size.height <= 0.0 {
Task::done(Message::ControlScreenshot(path, None))
} else {
iced::window::screenshot(window)
.map(move |s| Message::ControlScreenshot(path.clone(), Some(s)))
}
})
// C11: do not capture in the same batch. Park until the next
// rendered frame (or the timer fallback) so geometry changes are
// visible rather than returning a stale frame.
self.control.capture_frame_pending = true;
#[cfg(target_arch = "wasm32")]
{
self.control.capture_deadline =
Some(iced::time::Instant::now() + std::time::Duration::from_secs(5));
}
Task::none()
}
"stop" => {
self.control.enabled = false;
@@ -1159,7 +1240,22 @@ impl OpenCADStudio {
}
pub(super) fn control_track(&mut self, task: Task<Message>) -> Task<Message> {
if task.units() == 0 {
let track_zero_unit = self.control.pending.as_ref().is_some_and(|p| {
p.request["op"] == "run"
&& p.request["cmd"]
.as_str()
.is_some_and(|cmd| {
matches!(
cmd.trim().to_ascii_uppercase().as_str(),
"SAVE" | "QSAVE"
)
})
});
// Most zero-unit tasks are `Task::none()` and have already completed
// synchronously. SAVE/QSAVE are the deliberate exception: the command
// dispatcher returns a zero-unit Task::done(Message::SaveFile), and
// the operation must wait for that message before reporting its state.
if task.units() == 0 && !track_zero_unit {
return task;
}
let Some(p) = self.control.pending.as_mut() else {
@@ -1337,6 +1433,7 @@ impl OpenCADStudio {
path: String,
screenshot: Option<iced::window::Screenshot>,
) {
let waited_for_frame = self.control.capture_after_redraw;
let result = (|| -> Result<Value, String> {
let s = screenshot
.ok_or("The window is minimized or has no size; restore it and capture again")?;
@@ -1455,9 +1552,27 @@ impl OpenCADStudio {
}
}
#[cfg(not(target_arch = "wasm32"))]
image
.save_with_format(&path, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
// No filesystem behind a web "path": encode the PNG in memory and
// hand it to the browser as a download named after the request's
// file name — the delivery every other web output uses.
#[cfg(target_arch = "wasm32")]
{
let mut png = Vec::new();
image
.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let download = path
.rsplit(['/', '\\'])
.next()
.filter(|name| !name.is_empty())
.unwrap_or("capture.png")
.to_string();
crate::sys::download_bytes(&download, &png);
}
let doc_unit = crate::app::properties::insunits_name(
self.tabs[self.active_tab]
@@ -1485,6 +1600,7 @@ impl OpenCADStudio {
"width": image.width(),
"height": image.height(),
"scale_factor": s.scale_factor,
"waited_for_frame": waited_for_frame,
"document_id": self.tabs[self.active_tab].id,
"revision": self.tabs[self.active_tab].edit_revision,
"camera_revision": self.tabs[self.active_tab].scene.camera_generation,
@@ -1506,13 +1622,88 @@ impl OpenCADStudio {
}
Err(e) => self.command_line.push_error(&e),
}
// `capture` is parked until this result (or error) arrives, rather
// than being settled by the task counter used for ordinary actions.
if let Some(p) = self
.control
.pending
.as_mut()
.filter(|p| p.request["op"] == "capture")
{
p.pending = 0;
}
self.control.capture_after_redraw = false;
self.control_settle();
}
/// A frame finished rendering (or the anti-hang timer expired): start the
/// screenshot only after the capture request has been parked.
pub(super) fn on_control_capture_frame(&mut self, redrew: bool) -> Task<Message> {
if !self.control.capture_frame_pending {
return Task::none();
}
let Some(path) = self
.control
.pending
.as_ref()
.filter(|p| p.request["op"] == "capture")
.and_then(|p| p.request["path"].as_str())
.map(str::to_owned)
else {
self.control.capture_frame_pending = false;
return Task::none();
};
// Web: iced's window screenshot needs a synchronous GPU map wasm
// cannot provide; the canvas readback (the save-thumbnail technique)
// only sees the drawing right after a presented frame, before the
// browser clears the WebGL buffer — so wait for a real frame.
#[cfg(target_arch = "wasm32")]
{
let expired = self
.control
.capture_deadline
.is_some_and(|deadline| iced::time::Instant::now() >= deadline);
if !redrew && !expired && crate::sys::page_visible() {
return Task::none();
}
self.control.capture_frame_pending = false;
self.control.capture_deadline = None;
self.control.capture_after_redraw = redrew;
// No frame to read (hidden tab or nothing redrawing): report the
// capture as failed rather than hand back a blank canvas.
let screenshot = if redrew {
crate::sys::capture_canvas()
} else {
None
};
Task::done(Message::ControlScreenshot(path, screenshot))
}
#[cfg(not(target_arch = "wasm32"))]
{
self.control.capture_frame_pending = false;
self.control.capture_after_redraw = redrew;
let Some(window) = self.main_window else {
return Task::done(Message::ControlScreenshot(path, None));
};
// A minimized window has a 0x0 surface and the renderer panics
// reading it back, so report instead of capturing.
iced::window::size(window).then(move |size| {
let path = path.clone();
if size.width <= 0.0 || size.height <= 0.0 {
Task::done(Message::ControlScreenshot(path, None))
} else {
iced::window::screenshot(window).map(move |screenshot| {
Message::ControlScreenshot(path.clone(), Some(screenshot))
})
}
})
}
}
}
mod actions;
pub(crate) mod vision;
pub(crate) use actions::property_json;
mod entities;
pub(crate) use entities::erase_block_definition;
#[cfg(not(target_arch = "wasm32"))]
pub(crate) mod http_bridge;
mod interactive;
@@ -1548,6 +1739,128 @@ mod tests {
r
}
}
#[test]
fn qsave_completion_reports_clean_document_state() {
let mut app = OpenCADStudio::new_for_test();
assert_eq!(request(&mut app, json!({"op":"new"}))["ok"], true);
assert_eq!(
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"}))["status"],
"completed"
);
let path = std::env::temp_dir().join(format!(
"ocs_qsave_state_{}_{}.dwg",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
let _ = std::fs::remove_file(&path);
let saved = request(
&mut app,
json!({"op":"save","path":path,"overwrite":true}),
);
assert_eq!(saved["ok"], true, "{saved}");
assert_eq!(
request(&mut app, json!({"op":"run","cmd":"LINE 0,5 10,5"}))["status"],
"completed"
);
let qsave = request(&mut app, json!({"op":"run","cmd":"QSAVE"}));
assert_eq!(qsave["status"], "completed", "{qsave}");
let document_id = qsave["state"]["document_id"].as_u64().unwrap();
let dirty = qsave["state"]["documents"]
.as_array()
.unwrap()
.iter()
.find(|document| document["id"].as_u64() == Some(document_id))
.and_then(|document| document["dirty"].as_bool());
assert_eq!(dirty, Some(false), "{qsave}");
let _ = std::fs::remove_file(path);
}
#[test]
fn qsave_save_failure_settles_instead_of_hanging() {
let mut app = OpenCADStudio::new_for_test();
assert_eq!(request(&mut app, json!({"op":"new"}))["ok"], true);
assert_eq!(
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"}))["status"],
"completed"
);
let dir = std::env::temp_dir().join(format!(
"ocs_qsave_fail_{}_{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos(),
));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("blocked.dwg");
let saved = request(
&mut app,
json!({"op":"save","path":path,"overwrite":true}),
);
assert_eq!(saved["ok"], true, "{saved}");
// A directory at the target path makes the next write fail
// deterministically; the operation must settle as failed instead of
// waiting forever on its task counter.
std::fs::remove_file(&path).unwrap();
std::fs::create_dir(&path).unwrap();
assert_eq!(
request(&mut app, json!({"op":"run","cmd":"LINE 0,5 10,5"}))["status"],
"completed"
);
let qsave = request(&mut app, json!({"op":"run","cmd":"QSAVE"}));
assert_eq!(qsave["status"], "failed", "{qsave}");
assert!(
qsave["error"].as_str().is_some_and(|error| !error.is_empty()),
"{qsave}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn capture_parks_for_the_next_frame_then_finishes_with_its_result() {
let mut app = OpenCADStudio::new_for_test();
app.main_window = Some(iced::window::Id::unique());
assert_eq!(request(&mut app, json!({"op":"new"}))["ok"], true);
let path = std::env::temp_dir().join(format!("ocs-capture-frame-{}.png", session_id()));
let state = app.control_state();
let parked = app
.control_request(json!({
"protocol":1,
"op":"capture",
"request_id":"cap-frame",
"document_id": state["document_id"],
"revision": state["revision"],
"path": path.to_string_lossy(),
}))
.0;
assert_eq!(parked["status"], "accepted", "{parked}");
assert!(app.control.capture_frame_pending, "capture should wait for a frame");
let running = app
.control_request(json!({"op":"operation","request_id":"cap-frame"}))
.0;
assert_eq!(running["status"], "running", "{running}");
let _frame_task = app.on_control_capture_frame(true);
assert!(!app.control.capture_frame_pending);
let screenshot = iced::window::Screenshot::new(
vec![0u8; 4 * 4 * 4],
iced::Size::new(4, 4),
1.0,
);
app.control_screenshot(path.to_string_lossy().into_owned(), Some(screenshot));
let done = app
.control_request(json!({"op":"operation","request_id":"cap-frame"}))
.0;
assert_eq!(done["status"], "completed", "{done}");
assert_eq!(done["result"]["waited_for_frame"], true, "{done}");
assert_eq!(done["result"]["width"], 4, "{done}");
assert!(path.exists(), "capture should be written");
let _ = std::fs::remove_file(&path);
}
#[test]
fn control_stepwise_drawing_undo_and_properties() {
let mut app = OpenCADStudio::new_for_test();
@@ -1608,6 +1921,40 @@ mod tests {
request(&mut app, json!({"op":"undo"}));
assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 0);
}
#[test]
fn selection_changes_are_published_to_the_event_stream() {
let mut app = OpenCADStudio::new_for_test();
request(&mut app, json!({"op":"new"}));
request(&mut app, json!({"op":"run","cmd":"LINE 0,0 10,0"}));
let handle = request(&mut app, json!({"op":"query","type":"Line"}))["entities"][0]["handle"]
.as_str()
.unwrap()
.to_string();
let before = app.control_state()["selection_revision"].as_u64().unwrap();
let selected = app.automation_op(&format!(
r#"{{"op":"select","handles":["{handle}"]}}"#
));
assert_eq!(selected["ok"], true, "{selected}");
let state = app.control_state();
assert_eq!(
state["selection_revision"].as_u64().unwrap(),
before + 1,
"selection revision should increment once: {state}"
);
assert!(
state["selection"].as_array().unwrap().contains(&json!(handle)),
"{state}"
);
let events = app.control_request(json!({"op":"events"})).0;
let last = events["events"].as_array().unwrap().last().unwrap();
assert_eq!(last["kind"], "selection", "{events}");
assert_eq!(last["document_id"], state["document_id"], "{events}");
assert!(
last["selection"].as_array().unwrap().contains(&json!(handle)),
"{events}"
);
}
#[test]
fn command_discovery_explains_batch_and_interactive_use() {
let mut app = OpenCADStudio::new_for_test();
@@ -1779,6 +2126,70 @@ mod tests {
stale["request_id"] = json!("stale");
assert_eq!(app.control_request(stale).0["code"], "stale_state");
}
#[test]
fn external_change_modal_actions_are_automatable() {
let mut app = OpenCADStudio::new_for_test();
let names = crate::app::automation_action_names();
for name in [
"external_change_reload",
"external_change_overwrite",
"external_change_save_as",
"external_change_cancel",
] {
assert!(names.contains(&name), "{name} should be listed");
}
// No conflict pending: the action is a harmless no-op, not an error.
let reply = request(
&mut app,
json!({
"op":"action",
"request_id":"external-action-noop",
"name":"external_change_reload"
}),
);
assert_eq!(reply["status"], "completed", "{reply}");
}
#[test]
fn pending_request_id_reuse_rejects_changed_payload() {
let mut app = OpenCADStudio::new_for_test();
app.main_window = Some(iced::window::Id::unique());
assert_eq!(request(&mut app, json!({"op":"new"}))["status"], "completed");
let pending = request(
&mut app,
json!({
"op":"user_select",
"request_id":"pending-reuse",
"prompt":"pick the first object"
}),
);
assert_eq!(pending["status"], "running", "{pending}");
let changed = request(
&mut app,
json!({
"op":"user_select",
"request_id":"pending-reuse",
"prompt":"pick a different object"
}),
);
assert_eq!(changed["code"], "request_id_reused", "{changed}");
assert!(
changed["error"]
.as_str()
.unwrap()
.contains("pending-reuse"),
"{changed}"
);
let cancelled = request(
&mut app,
json!({"op":"cancel","request_id":"cancel-pending-reuse"}),
);
assert_eq!(cancelled["status"], "cancelled", "{cancelled}");
}
#[test]
fn control_errors_and_missing_entities_do_not_report_success() {
let mut app = OpenCADStudio::new_for_test();
+96 -1
View File
@@ -131,6 +131,52 @@ fn sysvar_round_trips_and_refuses_unknowns() {
assert_eq!(r["code"], "unknown_sysvar");
}
#[test]
fn sysvar_batches_validate_atomically_and_undo_as_one_step() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let doc = state["document_id"].as_u64().unwrap();
mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"sv-atomic-layer","document_id":{doc},"entities":[{"type":"Point","location":[0,0],"layer":"Walls"}]}"#,
);
let i = app.active_tab;
// Isolate this operation's dirty/history assertions from the layer setup.
app.tabs[i].dirty = false;
let history_before = app.tabs[i].history.undo_stack.len();
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv-atomic-ok","document_id":{doc},"set":{{"ltscale":2.5,"clayer":"Walls"}}}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(app.tabs[i].scene.document.header.current_layer_name, "Walls");
assert_eq!(app.tabs[i].scene.document.header.linetype_scale, 2.5);
assert_eq!(app.tabs[i].active_layer, "Walls");
assert_eq!(app.tabs[i].layers.current_layer, "Walls");
assert_eq!(app.ribbon.active_layer, "Walls");
assert!(app.tabs[i].dirty);
assert_eq!(app.tabs[i].history.undo_stack.len(), history_before + 1);
// A bad later value must not leak the valid earlier value or an undo entry.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"sysvar","request_id":"sv-atomic-bad","document_id":{doc},"set":{{"ltscale":9.0,"clayer":"NOPE"}}}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "invalid_sysvar_value");
assert_eq!(app.tabs[i].scene.document.header.linetype_scale, 2.5);
assert_eq!(app.tabs[i].scene.document.header.current_layer_name, "Walls");
assert_eq!(app.tabs[i].history.undo_stack.len(), history_before + 1);
// One undo restores both header variables and all current-layer mirrors.
app.automation_op(r#"{"op":"undo"}"#);
assert_eq!(app.tabs[i].scene.document.header.linetype_scale, 1.0);
assert_eq!(app.tabs[i].scene.document.header.current_layer_name, "0");
assert_eq!(app.tabs[i].active_layer, "0");
assert_eq!(app.tabs[i].layers.current_layer, "0");
assert_eq!(app.ribbon.active_layer, "0");
}
#[test]
fn layout_create_and_page_setup_configure_sheets() {
let mut app = OpenCADStudio::new_for_test();
@@ -386,6 +432,37 @@ fn plot_per_page_writes_one_pdf_per_layout() {
}
}
#[test]
fn entities_create_supports_control_and_fit_point_splines() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let control = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"spline-control","document_id":{doc},"entities":[
{"type":"Spline","degree":2,"knots":[0,0,0,1,1,1],"control_points":[[0,0,0],[5,5,0],[10,0,0]],"weights":[1,2,1],"layer":"CURVES"}
]}"#,
);
assert_eq!(control["ok"], true, "{control}");
let fit = mutate(
&mut app,
r#"{"protocol":1,"op":"entities_create","request_id":"spline-fit","document_id":{doc},"entities":[
{"type":"Spline","degree":3,"fit_points":[[20,0],[25,5],[30,0]]}
]}"#,
);
assert_eq!(fit["ok"], true, "{fit}");
let query = app.automation_op(r#"{"op":"query","type":"Spline","detail":"geometry"}"#);
assert_eq!(query["count"], 2, "{query}");
let control_entity = query["entities"]
.as_array()
.unwrap()
.iter()
.find(|entity| entity["layer"] == "CURVES")
.unwrap();
assert_eq!(control_entity["degree"], 2, "{control_entity}");
assert_eq!(control_entity["control_points"].as_array().unwrap().len(), 3);
assert_eq!(control_entity["weights"], json!([1.0, 2.0, 1.0]));
}
#[test]
fn hatch_boundary_is_flattened_in_query() {
let mut app = OpenCADStudio::new_for_test();
@@ -474,11 +551,29 @@ fn block_delete_and_define_replace_manage_definitions() {
assert_eq!(count_type(&mut app, "Line"), 0, "old children erased");
assert_eq!(count_type(&mut app, "Circle"), 0, "old children erased");
// A definition the insert still points at is refused; "force":true erases
// the referencing inserts in the same undo step.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl1b","document_id":{doc},"name":"MARK"}}"#
));
assert_eq!(r["ok"], false);
assert_eq!(r["code"], "block_referenced");
assert_eq!(
count_type(&mut app, "Insert"),
1,
"the insert survived the refusal"
);
// Delete removes the insert, the markers, the children and the record.
let r = app.automation_op(&format!(
r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK"}}"#
r#"{{"protocol":1,"op":"block_delete","request_id":"bl2","document_id":{doc},"name":"MARK","force":true}}"#
));
assert_eq!(r["ok"], true, "{}", r["error"]);
assert_eq!(
r["result"]["references"].as_u64().unwrap(),
1,
"the forced delete reports what it overrode"
);
assert!(
r["result"]["erased"].as_u64().unwrap() >= 3,
"insert + markers + child"
+102 -7
View File
@@ -50,15 +50,38 @@ impl OpenCADStudio {
/// "set":{"MIRRTEXT":0}}` writes (one undo step, validated per name).
pub(super) fn control_sysvar(&mut self, req: &Value) -> Result<Task<Message>, Value> {
if let Some(set) = req["set"].as_object() {
self.push_undo_snapshot(self.active_tab, "SYSVAR");
let i = self.active_tab;
// Validate the complete batch before opening history or changing the
// document. Apart from making the operation atomic, this keeps a
// later bad variable from leaving an earlier variable half-applied.
{
let document = &self.tabs[i].scene.document;
for (name, value) in set {
validate_sysvar(document, name, value)?;
}
}
self.push_undo_snapshot(i, "SYSVAR");
let mut applied = serde_json::Map::new();
for (name, value) in set {
set_sysvar(&mut self.tabs[self.active_tab].scene.document, name, value).map_err(
|e| {
// Nothing committed yet — the snapshot is empty.
e
},
)?;
if name.eq_ignore_ascii_case("clayer") {
let layer = value
.as_str()
.expect("validate_sysvar checked CLAYER as a string");
self.set_current_layer_name(i, layer).map_err(|error| {
failure(
"invalid_sysvar_value",
format!("Layer {layer:?} could not be made current: {error}"),
)
})?;
} else {
set_sysvar(&mut self.tabs[i].scene.document, name, value)?;
}
// System variables are persisted drawing state, not merely a
// UI preference. CLAYER is already marked dirty by the unified
// layer path; keeping this assignment for every variable makes
// mixed batches follow the same contract.
self.tabs[i].dirty = true;
applied.insert(name.to_ascii_lowercase(), value.clone());
}
self.set_control_result(json!({ "set": applied }));
@@ -587,7 +610,79 @@ fn read_sysvar(scene: &crate::scene::Scene, name: &str) -> Option<Value> {
Some(value)
}
fn validate_sysvar(document: &codec::CadDocument, name: &str, value: &Value) -> Result<(), Value> {
let failure = |message: &str| crate::app::control::failure("invalid_sysvar_value", message);
match name.to_ascii_lowercase().as_str() {
"ltscale" => {
positive(value, "ltscale", failure)?;
}
"pdmode" => {
integer(value, "pdmode", failure)?;
}
"pdsize" => {
value
.as_f64()
.filter(|v| v.is_finite())
.ok_or_else(|| failure("pdsize needs a finite number"))?;
}
"celtscale" => {
positive(value, "celtscale", failure)?;
}
"textsize" => {
positive(value, "textsize", failure)?;
}
"filletrad" => {
value
.as_f64()
.filter(|v| v.is_finite() && *v >= 0.0)
.ok_or_else(|| failure("filletrad needs a finite number >= 0"))?;
}
"mirrtext" => {
// Keep the historical coercion used by set_sysvar: omitted or
// non-integer values resolve to the enabled state.
}
"insunits" => {
integer(value, "insunits", failure)?;
}
"osmode" => {
value
.as_i64()
.and_then(|v| i32::try_from(v).ok())
.ok_or_else(|| failure("osmode needs an integer"))?;
}
"clayer" => {
let name = value
.as_str()
.filter(|s| !s.is_empty())
.ok_or_else(|| failure("clayer needs a layer name"))?;
if document.layers.get(name).is_none() {
return Err(failure(&format!("Layer '{name}' does not exist")));
}
}
"ctextstyle" => {
let name = value
.as_str()
.filter(|s| !s.is_empty())
.ok_or_else(|| failure("ctextstyle needs a text style name"))?;
if document.text_styles.get(name).is_none() {
return Err(failure(&format!("Text style '{name}' does not exist")));
}
}
other => {
return Err(crate::app::control::failure(
"unknown_sysvar",
format!(
"System variable '{other}' is read-only or unknown. Writable: {}",
"celtscale, clayer, ctextstyle, filletrad, insunits, ltscale, mirrtext, osmode, pdmode, pdsize, textsize"
),
))
}
}
Ok(())
}
fn set_sysvar(document: &mut codec::CadDocument, name: &str, value: &Value) -> Result<(), Value> {
validate_sysvar(document, name, value)?;
let failure = |message: &str| crate::app::control::failure("invalid_sysvar_value", message);
let header = &mut document.header;
match name.to_ascii_lowercase().as_str() {
+12
View File
@@ -0,0 +1,12 @@
//! The app side of docking: pallets docked on the drawing view's edges (one
//! group shown per edge, listed in an icon strip) or floating over it.
//!
//! The persisted layout and its pure geometry live in [`crate::ui::dock`];
//! this module holds the transient state handling (`update`) and drawing
//! (`view`).
mod update;
mod view;
#[cfg(test)]
mod tests;
+690
View File
@@ -0,0 +1,690 @@
//! Dock behaviour through the app: drags, hover, menus, and the real view.
use crate::app::{Message, OpenCADStudio};
fn fresh() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app
}
#[test]
fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
let mut app = fresh();
app.show_block_palette = true;
let id = crate::ui::dock::PanelId::BlockPalette;
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
assert!(app.dock.auto_hides(id), "pin enables auto-hide");
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
assert!(!app.dock.auto_hides(id), "second pin disables auto-hide");
let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id));
assert!(!app.show_block_palette, "close dismisses the sidebar");
}
/// A hermetic app with a 1600×900 workspace and the default dock layout
/// (Properties left, block palette right), both shown.
/// Run `f` on a thread with the stack the application's own threads get:
/// building and driving the real view is deeper than a default test thread.
/// One at a time: two headless renderers made in parallel crash the process.
fn with_stack(f: impl FnOnce() + Send + 'static) {
static REAL_VIEW: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _one = REAL_VIEW.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
std::thread::Builder::new()
.stack_size(32 * 1024 * 1024)
.spawn(f)
.expect("spawn test thread")
.join()
.unwrap_or_else(|e| std::panic::resume_unwind(e));
}
fn dock_app() -> OpenCADStudio {
let mut app = fresh();
// Tests load the user's persisted config; reset the dock to a known
// state so this stays hermetic.
app.dock = Default::default();
app.dock.ensure_settings();
app.show_properties = true;
app.show_block_palette = true;
let i = app.active_tab;
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
app.win_size = (1600.0, 900.0).into();
app
}
/// Where the current dock drag would land, if it has started.
fn drag_target(app: &OpenCADStudio) -> Option<crate::ui::dock::DropTarget> {
app.dock_drag
.and_then(|d| d.movement())
.and_then(|(_, _, target)| target)
}
/// Press `id`'s title bar at `from`, drag to `to`, and return the target
/// shown just before release.
fn drag_panel(
app: &mut OpenCADStudio,
id: crate::ui::dock::PanelId,
from: iced::Point,
to: iced::Point,
) -> Option<crate::ui::dock::DropTarget> {
use crate::ui::dock::DockMsg;
let _ = app.on_dock(DockMsg::DockGrab(id));
let _ = app.on_dock(DockMsg::DragMove(from));
let _ = app.on_dock(DockMsg::DragMove(to));
let target = drag_target(app);
let _ = app.on_dock(DockMsg::DragRelease);
target
}
#[test]
fn dropping_on_a_pallets_top_or_bottom_half_stacks_above_or_below_it() {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, PanelId};
let mut app = dock_app();
let id = PanelId::BlockPalette;
assert_eq!(app.dock.location(id), Some((DockSide::Right, 0)));
let target = drag_panel(
&mut app,
id,
iced::Point::new(1500.0, 10.0),
iced::Point::new(150.0, 300.0),
);
assert_eq!(
target,
Some(DropTarget::Join {
side: DockSide::Left,
group: 0,
index: 0
})
);
assert_eq!(
app.dock.left[0].panels,
vec![PanelId::BlockPalette, PanelId::Properties]
);
assert!(app.dock.right.is_empty());
// The bottom half of a pallet stacks below it.
let target = drag_panel(
&mut app,
id,
iced::Point::new(150.0, 100.0),
iced::Point::new(150.0, 800.0),
);
assert_eq!(
target,
Some(DropTarget::Join {
side: DockSide::Left,
group: 0,
index: 2
})
);
assert_eq!(
app.dock.left[0].panels,
vec![PanelId::Properties, PanelId::BlockPalette]
);
}
#[test]
fn dock_dragging_a_lone_pallet_over_its_own_group_is_a_no_op() {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, PanelId};
let mut app = dock_app();
let before = app.dock.clone();
// The right edge's only group holds just the block palette; drag it
// around over itself.
let target = drag_panel(
&mut app,
PanelId::BlockPalette,
iced::Point::new(1500.0, 10.0),
iced::Point::new(1450.0, 600.0),
);
assert_eq!(
target,
Some(DropTarget::Join {
side: DockSide::Right,
group: 0,
index: 1
})
);
assert_eq!(app.dock, before);
}
#[test]
fn dock_click_without_drag_changes_nothing() {
let mut app = dock_app();
let before = app.dock.clone();
let id = crate::ui::dock::PanelId::BlockPalette;
let p = iced::Point::new(1500.0, 10.0);
let target = drag_panel(&mut app, id, p, iced::Point::new(1502.0, 11.0));
assert_eq!(target, None, "movement under the threshold is a click");
assert_eq!(app.dock, before);
}
#[test]
fn dock_drop_below_the_strip_groups_starts_a_new_shown_group() {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, PanelId};
let mut app = dock_app();
let (_, plus_top) = app.dock_strip_layout(DockSide::Left);
let target = drag_panel(
&mut app,
PanelId::BlockPalette,
iced::Point::new(1500.0, 10.0),
iced::Point::new(18.0, plus_top + 10.0),
);
assert_eq!(
target,
Some(DropTarget::Edge {
side: DockSide::Left,
index: 1
})
);
assert_eq!(app.dock.left.len(), 2);
assert_eq!(app.dock.left[1].panels, vec![PanelId::BlockPalette]);
// The new group is the one shown: the column holds only the palette.
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]);
}
#[test]
fn dock_icon_press_switches_the_shown_group() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.show_browser = true;
app.dock.left = vec![
DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]),
DockGroup::single(PanelId::Browser),
];
app.dock.right.clear();
// Group 0 shows both stacked pallets.
assert_eq!(app.dock_slot_spans(DockSide::Left).len(), 2);
let _ = app.on_dock(DockMsg::IconPress(PanelId::Browser));
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(0, 0.0, 900.0)]);
// Each group keeps its own width.
app.dock.set_group_width(DockSide::Left, 1, 400.0);
assert_eq!(app.dock_column_width(DockSide::Left), 400.0);
let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette));
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock_column_width(DockSide::Left), 260.0);
}
#[test]
fn dock_drop_over_the_viewport_floats_the_panel() {
use crate::ui::dock::{DropTarget, PanelId};
let mut app = dock_app();
let target = drag_panel(
&mut app,
PanelId::BlockPalette,
iced::Point::new(1500.0, 10.0),
iced::Point::new(800.0, 400.0),
);
assert!(matches!(target, Some(DropTarget::Float { .. })));
assert!(app.dock.right.is_empty());
let f = app
.dock
.float_rect(PanelId::BlockPalette)
.expect("floating");
assert!(f.x > 600.0 && f.x < 800.0 && f.y > 350.0 && f.y < 400.0);
// Dragging a floating panel keeps the grab point under the pointer.
let target = drag_panel(
&mut app,
PanelId::BlockPalette,
iced::Point::new(f.x + 20.0, f.y + 10.0),
iced::Point::new(f.x + 120.0, f.y + 60.0),
);
assert_eq!(
target,
Some(DropTarget::Float {
x: f.x + 100.0,
y: f.y + 50.0
})
);
// Opening a floating panel must not re-dock it.
assert!(app.dock.is_placed(PanelId::BlockPalette));
}
#[test]
fn dock_dragging_an_icon_into_another_group_lands_between_its_icons() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, DropTarget, PanelId, STRIP_CELL_H};
let mut app = dock_app();
app.show_browser = true;
app.dock.left = vec![
DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]),
DockGroup::single(PanelId::BlockPalette),
];
app.dock.right.clear();
let (layout, _) = app.dock_strip_layout(DockSide::Left);
// Drag the Blocks icon (group 1) to between Properties and Browser.
let _ = app.on_dock(DockMsg::IconPress(PanelId::BlockPalette));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(
20.0,
layout[1].icons_top + 10.0,
)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(
20.0,
layout[0].icons_top + STRIP_CELL_H + 2.0,
)));
assert_eq!(
drag_target(&app),
Some(DropTarget::Join {
side: DockSide::Left,
group: 0,
index: 1
})
);
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock.left.len(), 1, "the emptied group goes away");
assert_eq!(
app.dock.left[0].panels,
vec![PanelId::Properties, PanelId::BlockPalette, PanelId::Browser]
);
}
#[test]
fn dock_grip_drag_moves_a_whole_group() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.show_browser = true;
app.dock.left = vec![
DockGroup::stack(vec![PanelId::Properties, PanelId::Browser]),
DockGroup::single(PanelId::BlockPalette),
];
app.dock.right.clear();
let (layout, plus_top) = app.dock_strip_layout(DockSide::Left);
let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 0));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(
18.0,
layout[0].top + 2.0,
)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, plus_top + 10.0)));
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock.left[0].panels, vec![PanelId::BlockPalette]);
assert_eq!(
app.dock.left[1].panels,
vec![PanelId::Properties, PanelId::Browser]
);
// A group dropped over the viewport does not float or split up.
let before = app.dock.clone();
let _ = app.on_dock(DockMsg::GroupGrab(DockSide::Left, 1));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(18.0, 300.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(800.0, 400.0)));
assert_eq!(drag_target(&app), None);
let _ = app.on_dock(DockMsg::DragRelease);
assert_eq!(app.dock, before);
}
#[test]
fn dock_closed_pallets_take_no_room_and_empty_groups_hide() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, PanelId};
let mut app = dock_app();
app.dock.left = vec![
DockGroup::stack(vec![PanelId::BlockPalette, PanelId::Properties]),
DockGroup::single(PanelId::Browser),
];
app.show_block_palette = false;
app.show_browser = false;
// Only Properties is open: one listed group, full height.
assert_eq!(app.dock_visible_groups(DockSide::Left), vec![0]);
assert_eq!(app.dock_slot_spans(DockSide::Left), vec![(1, 0.0, 900.0)]);
// A shown group with nothing open falls back to one that has.
app.dock.show_group(DockSide::Left, 1);
assert_eq!(app.dock_shown_group(DockSide::Left), Some(0));
}
#[test]
fn dock_splitter_drag_changes_pallet_heights() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.dock.left = vec![DockGroup::stack(vec![
PanelId::BlockPalette,
PanelId::Properties,
])];
app.dock.right.clear();
let _ = app.on_dock(DockMsg::SplitGrab(DockSide::Left, 0, 0, 1));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 675.0)));
let _ = app.on_dock(DockMsg::DragRelease);
// 225 px of a 900 px edge holding 2 weight units = 0.5 units.
let w = &app.dock.left[0].weights;
assert!((w[0] - 1.5).abs() < 1e-4 && (w[1] - 0.5).abs() < 1e-4);
let _ = app.on_dock(DockMsg::SplitReset(DockSide::Left, 0));
assert_eq!(app.dock.left[0].weights, vec![1.0, 1.0]);
}
#[test]
fn dock_hover_reveals_groups_only_on_an_auto_hiding_edge() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.show_browser = true;
app.dock.left.push(DockGroup::single(PanelId::Browser));
// The pin on any docked pallet turns auto-hide on for its edge.
let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties));
assert!(app.dock.edge_auto_hide(DockSide::Left));
assert!(!app.dock.edge_auto_hide(DockSide::Right));
assert!(!app.dock_edge_expanded(DockSide::Left));
// Hovering an icon flies its group out.
let _ = app.on_dock(DockMsg::Hover(PanelId::Browser));
// ...but only once the pointer rests: the reveal waits for the delay.
assert!(!app.dock_edge_expanded(DockSide::Left));
let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen));
assert!(app.dock_edge_expanded(DockSide::Left));
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
// Leaving and coming back within the delay keeps it open.
let _ = app.on_dock(DockMsg::HoverExit);
let stale = app.dock_hover_gen;
let _ = app.on_dock(DockMsg::HoverStay);
let _ = app.on_dock(DockMsg::HoverSettled(stale));
assert!(app.dock_edge_expanded(DockSide::Left));
let _ = app.on_dock(DockMsg::HoverExit);
let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen));
assert!(!app.dock_edge_expanded(DockSide::Left));
// Without auto-hide, hovering does not switch groups (clicks do).
let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::Properties));
let _ = app.on_dock(DockMsg::Hover(PanelId::Properties));
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
assert!(app.dock_edge_expanded(DockSide::Left));
}
#[test]
fn dock_float_resize_grows_the_panel() {
use crate::ui::dock::{DockMsg, FloatPanel, PanelId};
let mut app = dock_app();
app.dock.float(FloatPanel::new(
PanelId::BlockPalette,
100.0,
100.0,
260.0,
300.0,
));
let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, false));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(360.0, 400.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(400.0, 450.0)));
let _ = app.on_dock(DockMsg::DragRelease);
let f = app.dock.float_rect(PanelId::BlockPalette).unwrap();
assert_eq!((f.w, f.h), (300.0, 350.0));
// The bottom-left grip grows the panel leftward, keeping its right edge.
let _ = app.on_dock(DockMsg::FloatResizeGrab(PanelId::BlockPalette, true));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(100.0, 450.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(60.0, 450.0)));
let _ = app.on_dock(DockMsg::DragRelease);
let g = app.dock.float_rect(PanelId::BlockPalette).unwrap();
assert_eq!((g.x, g.w, g.h), (f.x - 40.0, 340.0, 350.0));
}
#[test]
fn dock_edge_menu_adds_and_hides_pallets() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId};
let mut app = dock_app();
app.show_browser = false;
let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left)));
// Checking a closed pallet opens it as a new, shown group here.
let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser));
assert!(app.show_browser);
assert_eq!(app.dock.left.len(), 2);
assert_eq!(app.dock.left[1].panels, vec![PanelId::Browser]);
assert_eq!(app.dock_shown_group(DockSide::Left), Some(1));
assert_eq!(app.dock_edge_menu, None, "the menu closes after adding");
let _ = app.on_dock(DockMsg::EdgeMenu(Some(DockSide::Left)));
// Unchecking hides it but keeps its place.
let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser));
assert!(!app.show_browser);
assert_eq!(
app.dock.location(PanelId::Browser),
Some((DockSide::Left, 1))
);
// Opening it again from the same edge reuses that place.
let _ = app.on_dock(DockMsg::EdgeMenuToggle(DockSide::Left, PanelId::Browser));
assert_eq!(app.dock.left.len(), 2);
// A pallet open on the other edge is not checked here: picking it
// moves it over instead of hiding it.
let _ = app.on_dock(DockMsg::EdgeMenuToggle(
DockSide::Left,
PanelId::BlockPalette,
));
assert!(app.show_block_palette);
assert!(app.dock.right.is_empty());
assert_eq!(
app.dock.location(PanelId::BlockPalette),
Some((DockSide::Left, 2))
);
}
#[test]
fn dock_divider_resizes_only_its_group() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockGroup, DockMsg, PanelId};
let mut app = dock_app();
app.dock.left = vec![
DockGroup::stack(vec![PanelId::Properties, PanelId::BlockPalette]),
DockGroup::single(PanelId::Count),
];
app.dock.right.clear();
let _ = app.on_dock(DockMsg::ResizeGrab(PanelId::Properties));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(300.0, 100.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(330.0, 100.0)));
let _ = app.on_dock(DockMsg::DragRelease);
// The group was 260 wide (its widest pallet); it is now 290, while
// the other group and the pallets' own widths are untouched.
assert_eq!(app.dock_group_width(DockSide::Left, 0), 290.0);
assert_eq!(app.dock_group_width(DockSide::Left, 1), 280.0);
assert_eq!(app.dock.settings(PanelId::Properties).width, 250.0);
}
#[test]
fn layers_command_opens_a_floating_pallet_and_toggles_it_closed() {
use crate::ui::dock::PanelId;
let mut app = dock_app();
let _ = app.update(Message::ToggleLayers);
assert!(app.show_layers);
assert!(app.active_modal.is_none(), "no modal backdrop");
let f = app
.dock
.float_rect(PanelId::Layers)
.expect("floats on first use");
assert!(f.x > 0.0 && f.y >= 0.0);
let _ = app.update(Message::ToggleLayers);
assert!(!app.show_layers);
// Its place is kept for next time.
assert!(app.dock.float_rect(PanelId::Layers).is_some());
}
/// A headless renderer for driving the real view.
fn headless_renderer() -> iced::Renderer {
iced_test::futures::futures::executor::block_on(
<iced::Renderer as iced_test::core::renderer::Headless>::new(
iced_test::core::renderer::Settings::default(),
None,
),
)
.expect("headless renderer")
}
/// Size the real view is laid out at.
const VIEW_SIZE: iced::Size = iced::Size::new(1600.0, 900.0);
/// Centre of the first text `label` in the real view.
fn find_in_view(app: &OpenCADStudio, label: &str) -> iced::Point {
use iced_test::core::widget;
use iced_test::runtime::user_interface::{Cache, UserInterface};
use iced_test::Selector;
let mut renderer = headless_renderer();
let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, Cache::default(), &mut renderer);
let mut find = Selector::find(label);
ui.operate(&renderer, &mut widget::operation::black_box(&mut find));
match widget::Operation::finish(&find) {
widget::operation::Outcome::Some(Some(target)) => {
target.visible_bounds().expect("visible").center()
}
_ => panic!("{label} not found"),
}
}
/// Drive the real view through iced's `UserInterface` with the pointer at
/// `at`: feed `events` one at a time, and between events hand the
/// published messages to the app and rebuild the view while keeping the
/// widget-tree cache, as the runtime does.
fn drive_view(app: &mut OpenCADStudio, at: iced::Point, events: &[iced::Event]) {
use iced_test::core::{mouse, shell, window};
use iced_test::runtime::user_interface::{Cache, UserInterface};
let mut renderer = headless_renderer();
let mut cache = Cache::default();
let cursor = mouse::Cursor::Available(at);
for event in events {
let mut messages = Vec::new();
{
let mut ui = UserInterface::build(app.view_main(), VIEW_SIZE, cache, &mut renderer);
let _ = ui.update(
&window::Headless,
&shell::Waker::noop(),
std::slice::from_ref(event),
cursor,
&mut renderer,
&mut messages,
);
cache = ui.into_cache();
}
for message in messages {
let _ = app.update(message);
}
}
}
#[test]
fn docked_title_bar_double_click_floats_the_pallet_in_the_real_view() {
// Building the real view is deeper than a default test thread's stack.
with_stack(|| {
use crate::ui::dock::PanelId;
let mut app = dock_app();
let press = iced::Event::Mouse(iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left));
let release = iced::Event::Mouse(iced::mouse::Event::ButtonReleased(
iced::mouse::Button::Left,
));
// The block palette's title text sits in its docked title bar.
let title = find_in_view(&app, "Blocks");
drive_view(
&mut app,
title,
&[press.clone(), release.clone(), press, release],
);
assert!(
app.dock.float_rect(PanelId::BlockPalette).is_some(),
"a double-click on a docked title bar floats the pallet"
);
});
}
#[test]
fn hovering_a_strip_icon_reveals_an_auto_hiding_edge_in_the_real_view() {
// Building the real view is deeper than a default test thread's stack.
with_stack(|| {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId, STRIP_CELL_H, STRIP_PAD};
let mut app = dock_app();
// Mirror the layout onto the left so the block palette's title marks
// where the workspace starts, then auto-hide the right edge.
let title = find_in_view(&app, "Blocks");
let _ = app.on_dock(DockMsg::AutoCollapseToggle(PanelId::BlockPalette));
assert!(!app.dock_edge_expanded(DockSide::Right));
// The title bar text sits ~13 px below the workspace top; the strip's
// first icon is centred STRIP_PAD + half a cell below it.
let workspace_top = title.y - 13.0;
let icon = iced::Point::new(
VIEW_SIZE.width - crate::ui::dock::DOCK_STRIP_W / 2.0,
workspace_top + STRIP_PAD + STRIP_CELL_H / 2.0,
);
drive_view(
&mut app,
icon,
&[iced::Event::Mouse(iced::mouse::Event::CursorMoved {
position: icon,
})],
);
let _ = app.on_dock(DockMsg::HoverSettled(app.dock_hover_gen));
assert!(
app.dock_edge_expanded(DockSide::Right),
"hovering the icon reveals the auto-hiding edge"
);
});
}
#[test]
fn layers_ribbon_button_opens_the_pallet_without_staying_highlighted() {
let mut app = dock_app();
// The ribbon's LAYERS button runs the LAYERS command.
let click = || Message::RibbonToolClick {
tool_id: "LAYERS".to_string(),
event: crate::modules::ModuleEvent::Command("LAYERS".to_string()),
};
let _ = app.update(click());
assert!(app.show_layers);
assert_eq!(app.ribbon.active_tool(), None);
// Pressing it again keeps it open, like the Blocks palette's button.
let _ = app.update(click());
assert!(app.show_layers);
}
#[test]
fn dock_double_clicks_float_and_dock_panels() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId};
let mut app = dock_app();
let _ = app.on_dock(DockMsg::FloatOut(PanelId::Properties));
assert!(app.dock.left.is_empty());
let f = app.dock.float_rect(PanelId::Properties).expect("floating");
assert!(f.x > 0.0, "floats clear of the left edge");
let _ = app.on_dock(DockMsg::DockTo(PanelId::Properties, DockSide::Right));
assert!(app.dock.floating.is_empty());
assert_eq!(
app.dock.location(PanelId::Properties),
Some((DockSide::Right, 1))
);
}
#[test]
fn blockpalette_width_reset() {
use crate::app::config::DockSide;
let mut app = dock_app();
let id = crate::ui::dock::PanelId::BlockPalette;
app.dock.set_group_width(DockSide::Right, 0, 500.0);
let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id));
assert_eq!(app.dock.group_width(DockSide::Right, 0), 260.0);
}
#[test]
fn floating_menu_docks_and_allow_docking_keeps_a_drag_floating() {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, DropTarget, PanelId};
let mut app = dock_app();
let _ = app.on_dock(DockMsg::FloatOut(PanelId::BlockPalette));
// With Allow docking off, a drag onto the left edge's strip stays
// floating.
let _ = app.on_dock(DockMsg::FloatMenu(Some(PanelId::BlockPalette)));
let _ = app.on_dock(DockMsg::FloatDockingToggle(PanelId::BlockPalette));
assert_eq!(app.dock_float_menu, None);
assert!(!app.dock.float_rect(PanelId::BlockPalette).unwrap().docking);
let _ = app.on_dock(DockMsg::DockGrab(PanelId::BlockPalette));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(500.0, 300.0)));
let _ = app.on_dock(DockMsg::DragMove(iced::Point::new(10.0, 60.0)));
assert!(matches!(drag_target(&app), Some(DropTarget::Float { .. })));
let _ = app.on_dock(DockMsg::DragRelease);
let f = app
.dock
.float_rect(PanelId::BlockPalette)
.expect("still floating");
assert!(!f.docking, "moving it keeps the choice");
// The menu's Dock left still docks it.
let _ = app.on_dock(DockMsg::FloatMenu(Some(PanelId::BlockPalette)));
let _ = app.on_dock(DockMsg::DockTo(PanelId::BlockPalette, DockSide::Left));
assert_eq!(app.dock_float_menu, None);
assert_eq!(
app.dock.location(PanelId::BlockPalette).map(|(s, _)| s),
Some(DockSide::Left)
);
}
+800
View File
@@ -0,0 +1,800 @@
//! Dock state changes: drags, hover, open / close, and the queries the view
//! and drop targeting share.
use crate::app::{Message, OpenCADStudio};
/// Pointer travel (px) before pressing a title bar or icon becomes a drag,
/// so a plain click never moves anything.
const DOCK_DRAG_THRESHOLD: f32 = 5.0;
/// Resolves once the dock hover delay has passed (a sleeping helper thread;
/// the web build has none and resolves at once).
fn hover_delay() -> impl std::future::Future<Output = ()> {
#[cfg(not(target_arch = "wasm32"))]
let rx = {
let (tx, rx) = iced::futures::channel::oneshot::channel::<()>();
std::thread::spawn(move || {
std::thread::sleep(crate::ui::dock::DOCK_HOVER_DELAY);
let _ = tx.send(());
});
rx
};
async move {
#[cfg(not(target_arch = "wasm32"))]
let _ = rx.await;
}
}
impl OpenCADStudio {
/// Dock chrome interaction (grab / pin / resize / hover / move) applied to
/// whichever panel the message names.
pub(crate) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
use crate::app::config::DockSide;
use crate::ui::dock::{DockDrag, DockMsg, FloatPanel};
match m {
DockMsg::DockGrab(id) => self.dock_begin(DockDrag::moving(id, None), Some(id)),
DockMsg::IconPress(id) => {
// An icon press shows the icon's group and may also start
// dragging the pallet; a plain click never passes the drag
// threshold.
if self.dock.show_group_of(id) {
self.save_config();
}
self.dock_begin(DockDrag::moving(id, None), Some(id));
}
DockMsg::GroupGrab(side, gi) => {
if let Some(&id) = self.dock_group_visible(side, gi).first() {
self.dock_begin(DockDrag::moving(id, Some((side, gi))), Some(id));
}
}
DockMsg::ResizeGrab(id) => self.dock_begin(DockDrag::Width(id), Some(id)),
DockMsg::SplitGrab(side, group, upper, lower) => self.dock_begin(
DockDrag::Split {
side,
group,
upper,
lower,
},
None,
),
DockMsg::FloatResizeGrab(panel, from_left) => {
self.dock.raise_float(panel);
self.dock_begin(DockDrag::FloatSize { panel, from_left }, None);
}
DockMsg::SplitReset(side, gi) => {
self.dock.reset_splits(side, gi);
self.save_config();
}
DockMsg::WidthReset(id) => {
// Only docked pallets have the divider that sends this.
if let Some((side, gi)) = self.dock.location(id) {
self.dock.reset_group_width(side, gi);
self.save_config();
}
}
DockMsg::TitleHover(id) => self.dock_title_hover = Some(id),
DockMsg::TitleHoverEnd(id) => {
if self.dock_title_hover == Some(id) {
self.dock_title_hover = None;
}
}
DockMsg::GripHover(side, gi) => self.dock_grip_hover = Some((side, gi)),
DockMsg::GripHoverEnd(side, gi) => {
if self.dock_grip_hover == Some((side, gi)) {
self.dock_grip_hover = None;
}
}
DockMsg::FloatRaise(id) => self.dock.raise_float(id),
DockMsg::FloatOut(id) => {
// Float beside the edge it was docked on, near the top.
let (ww, _) = self.dock_workspace_size();
let (w, h) = self.dock_float_size(id);
let strip = crate::ui::dock::DOCK_STRIP_W;
let x = match self.dock.location(id) {
Some((DockSide::Right, _)) => {
ww - strip - self.dock_column_width(DockSide::Right) - w - 60.0
}
_ => strip + self.dock_column_width(DockSide::Left) + 40.0,
};
self.dock_drag = None;
if self.dock.float(FloatPanel::new(id, x.max(0.0), 40.0, w, h)) {
self.save_config();
}
}
DockMsg::DockTo(id, side) => {
self.dock_drag = None;
self.dock_float_menu = None;
if self.dock.dock(id, side, usize::MAX) {
self.save_config();
}
}
DockMsg::FloatMenu(id) => {
self.dock_float_menu = id;
self.dock_float_menu_at = self.dock_float_pointer;
if let Some(id) = id {
self.dock.raise_float(id);
}
}
DockMsg::FloatBarPointer(p) => self.dock_float_pointer = p,
DockMsg::FloatDockingToggle(id) => {
self.dock_float_menu = None;
if self.dock.toggle_float_docking(id) {
self.save_config();
}
}
DockMsg::EdgeMenu(side) => self.dock_edge_menu = side,
DockMsg::EdgeMenuToggle(side, id) => {
// Checked = open and docked on this edge: unchecking hides it.
// Anything else (closed, floating, on the other edge) moves
// here and opens, in its own group here if it has one, else
// as a new group; adding a pallet ends the menu's job.
let here = self.dock.location(id).map(|(s, _)| s) == Some(side);
if !here {
self.dock.dock(id, side, usize::MAX);
} else if self.dock_panel_visible(id) {
let task = self.dock_set_open(id, false);
self.save_config();
return task;
}
self.dock_edge_menu = None;
let task = self.dock_set_open(id, true);
self.save_config();
return task;
}
DockMsg::AutoCollapseToggle(id) => {
// Docked: auto-hide for the whole edge; floating: this pallet.
if self.dock.toggle_auto_hide(id) {
self.dock_peek = None;
}
self.save_config();
}
DockMsg::Close(id) => return self.dock_set_open(id, false),
DockMsg::Hover(_)
| DockMsg::HoverEnd(_)
| DockMsg::HoverExit
| DockMsg::HoverStay
| DockMsg::HoverSettled(_) => return self.dock_hover(m),
DockMsg::DragMove(point) => self.dock_drag_move(point),
DockMsg::DragRelease => self.dock_drag_release(),
}
iced::Task::none()
}
/// Pointer entering / leaving strip icons, edge columns and floating
/// frames, and the delayed auto-hide reveal / hide that follows.
fn dock_hover(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
use crate::ui::dock::{DockMsg, HoverPending};
match m {
DockMsg::Hover(id) => {
self.dock_icon_hover = Some(id);
// Only an auto-hiding edge (or floating pallet) reacts to
// hover: after a short rest it reveals the hovered pallet's
// group. Otherwise groups switch on click. Ignored mid-drag,
// when the pointer is over the preview rather than the strip.
if self.dock_drag.is_none() && self.dock.auto_hides(id) {
if self.dock_peek == Some(id) {
self.dock_hover_cancel();
} else {
return self.dock_hover_arm(HoverPending::Reveal(id));
}
}
}
DockMsg::HoverEnd(id) => {
if self.dock_icon_hover == Some(id) {
self.dock_icon_hover = None;
}
}
DockMsg::HoverExit => {
if self.dock_drag.is_none()
&& self.dock_peek.is_some_and(|id| self.dock.auto_hides(id))
{
return self.dock_hover_arm(HoverPending::Hide);
}
// Left before a pending reveal ran out: it never happens.
self.dock_hover_cancel();
}
DockMsg::HoverStay => {
if self.dock_hover_pending == Some(HoverPending::Hide) {
self.dock_hover_cancel();
}
}
DockMsg::HoverSettled(gen) => {
if gen == self.dock_hover_gen && self.dock_drag.is_none() {
match self.dock_hover_pending.take() {
Some(HoverPending::Reveal(id)) if self.dock.auto_hides(id) => {
self.dock_reveal(id)
}
Some(HoverPending::Hide)
if self.dock_peek.is_some_and(|id| self.dock.auto_hides(id)) =>
{
self.dock_peek = None;
}
_ => {}
}
}
}
_ => {}
}
iced::Task::none()
}
/// Pointer released: land what was dragged and save a changed layout.
fn dock_drag_release(&mut self) {
use crate::ui::dock::{DockDrag, DropTarget, FloatPanel};
let mut changed = false;
match self.dock_drag.take() {
Some(DockDrag::Move {
group: Some((side, gi)),
target: Some(DropTarget::Edge { side: to, index }),
..
}) => changed = self.dock.move_group(side, gi, to, index),
Some(DockDrag::Move {
panel,
group: None,
target: Some(target),
..
}) => {
changed = match target {
DropTarget::Edge { side, index } => self.dock.dock(panel, side, index),
DropTarget::Join { side, group, index } => {
self.dock.join_group(panel, side, group, index)
}
DropTarget::Float { x, y } => {
let (w, h) = self.dock_float_size(panel);
// A floating panel keeps its docking choice.
let docking = self.dock.float_rect(panel).is_none_or(|f| f.docking);
self.dock.float(FloatPanel {
docking,
..FloatPanel::new(panel, x, y, w, h)
})
}
};
}
Some(DockDrag::Move { .. } | DockDrag::LayerColumn) | None => {}
Some(DockDrag::Width(_) | DockDrag::Split { .. } | DockDrag::FloatSize { .. }) => {
changed = true;
}
}
if changed {
self.save_config();
}
self.dock_drag_last = None;
self.xref_col_drag = None;
self.xref_col_last = None;
self.xref_split_drag = false;
}
/// Pointer motion while something in the dock is dragged.
fn dock_drag_move(&mut self, point: iced::Point) {
// NOTE: xref column drags intentionally do NOT ride this path: its
// points live in workspace space while the header tracker reports
// header-local points, and mixing the two produced a one-time jump
// plus a stuck drag. Column moves arrive via Message::XrefColMove
// only.
use crate::app::config::DockSide;
use crate::ui::dock::{DockDrag, DropTarget};
let last = self.dock_drag_last.replace(point);
let Some(drag) = self.dock_drag else {
return;
};
match drag {
DockDrag::Move {
panel,
group,
origin,
target,
grab,
} => {
let origin = origin.unwrap_or(point);
let moved = (point.x - origin.x).hypot(point.y - origin.y);
let started = target.is_some() || moved >= DOCK_DRAG_THRESHOLD;
// Hold a floating panel where it was grabbed; a docked one by
// its title bar.
let grab = if target.is_none() && started {
match self.dock.float_rect(panel) {
Some(f) => iced::Vector::new(origin.x - f.x, origin.y - f.y),
None => iced::Vector::new(
(self.dock.width(panel, self.win_size.0) * 0.5).min(120.0),
14.0,
),
}
} else {
grab
};
let target = if started {
// A floating panel that may not dock only floats.
let found = match self.dock.float_rect(panel) {
Some(f) if !f.docking => self.dock_float_target(point, grab),
_ => self.dock_drop_target(point, grab),
};
// A whole group only moves between group positions: it
// cannot join another group or float.
match (group, found) {
(Some(_), found @ DropTarget::Edge { .. }) | (None, found) => Some(found),
(Some(_), _) => None,
}
} else {
target
};
self.dock_drag = Some(DockDrag::Move {
panel,
group,
origin: Some(origin),
target,
grab,
});
}
DockDrag::Split {
side,
group,
upper,
lower,
} => {
let Some(last) = last else { return };
let Some(weights) = self.dock.groups(side).get(group).map(|g| &g.weights) else {
return;
};
let (_, avail) = self.dock_workspace_size();
let total: f32 = self
.dock_slot_spans(side)
.iter()
.filter_map(|(i, _, _)| weights.get(*i))
.sum();
if avail > 0.0 && total > 0.0 {
let per_px = total / avail;
self.dock.shift_split(
side,
group,
upper,
lower,
(point.y - last.y) * per_px,
crate::ui::dock::GROUP_MIN_H * per_px,
);
}
}
DockDrag::FloatSize { panel, from_left } => {
let (Some(last), Some(f)) = (last, self.dock.float_rect(panel)) else {
return;
};
let dx = point.x - last.x;
self.dock.resize_float(
panel,
if from_left { f.w - dx } else { f.w + dx },
f.h + point.y - last.y,
from_left,
);
}
DockDrag::LayerColumn => {
let Some(last) = last else { return };
self.layer_name_col_w =
(self.layer_name_col_w + point.x - last.x).clamp(60.0, 640.0);
}
DockDrag::Width(id) => {
let Some(last) = last else { return };
// The divider sizes the docked pallet's group.
if let Some((side, gi)) = self.dock.location(id) {
let dx = point.x - last.x;
let delta = if side == DockSide::Left { dx } else { -dx };
let cur = self.dock_group_width(side, gi) + delta;
self.dock.set_group_width(side, gi, cur);
}
}
}
}
/// Start `drag`, peeking `peek` (a pallet being dragged or resized stays
/// shown on an auto-hiding edge).
fn dock_begin(
&mut self,
drag: crate::ui::dock::DockDrag,
peek: Option<crate::ui::dock::PanelId>,
) {
if let crate::ui::dock::DockDrag::Move { panel, .. } = drag {
self.dock.raise_float(panel);
}
self.dock_drag = Some(drag);
self.dock_drag_last = None;
self.dock_edge_menu = None;
self.dock_float_menu = None;
if peek.is_some() {
self.dock_peek = peek;
}
}
/// Arm a delayed reveal or hide; it applies when its timer reports back
/// unless a newer hover superseded it.
fn dock_hover_arm(&mut self, target: crate::ui::dock::HoverPending) -> iced::Task<Message> {
self.dock_hover_gen = self.dock_hover_gen.wrapping_add(1);
self.dock_hover_pending = Some(target);
let gen = self.dock_hover_gen;
iced::Task::perform(hover_delay(), move |()| {
Message::Dock(crate::ui::dock::DockMsg::HoverSettled(gen))
})
}
/// Drop any pending delayed reveal / hide.
fn dock_hover_cancel(&mut self) {
self.dock_hover_gen = self.dock_hover_gen.wrapping_add(1);
self.dock_hover_pending = None;
}
/// Bring `id` into view: show its group on its edge and, where it
/// auto-hides, keep it revealed until the pointer leaves.
pub(crate) fn dock_reveal(&mut self, id: crate::ui::dock::PanelId) {
self.dock.show_group_of(id);
self.dock_peek = Some(id);
}
/// Open or close pallet `id`, running its usual open path (refreshes,
/// ribbon highlight) or close path. An opened pallet is revealed where
/// the layout has it.
pub(crate) fn dock_set_open(
&mut self,
id: crate::ui::dock::PanelId,
open: bool,
) -> iced::Task<Message> {
use crate::ui::dock::{DockDrag, PanelId};
let mut task = iced::Task::none();
if open {
if !self.dock_panel_visible(id) {
match id {
PanelId::Properties => {
self.show_properties = true;
self.ribbon.set_properties(true);
}
PanelId::GraphicAttributes => {
self.show_graphic_attributes = true;
self.ribbon.set_graphic_attributes(true);
self.save_config();
}
PanelId::BlockPalette => self.open_blocks_palette(None),
PanelId::ExternalReferences => {
self.show_external_references = true;
self.refresh_xref_manager();
}
PanelId::Browser => self.show_browser = true,
PanelId::NodeGraph => {
task = self.on_graph(crate::ui::node_graph::GraphMsg::Toggle);
}
PanelId::PointCloudManager => self.pc_manager.show = true,
PanelId::Count => self.set_count_palette(true),
PanelId::SheetSetManager => self.show_sheet_set_manager(true),
PanelId::Layers => {
self.sync_ribbon_layers();
self.show_layers = true;
}
}
}
self.dock_reveal(id);
return task;
}
match id {
PanelId::BlockPalette => {
self.show_block_palette = false;
self.block_palette.placing = None;
}
PanelId::ExternalReferences => self.show_external_references = false,
PanelId::Browser => self.show_browser = false,
PanelId::NodeGraph => self.show_node_graph = false,
PanelId::PointCloudManager => self.pc_manager.show = false,
PanelId::Count => {
self.count_palette.show = false;
self.ribbon.set_count_palette(false);
}
PanelId::SheetSetManager => {
self.sheet_set.show = false;
self.ribbon.set_sheet_set(false);
}
PanelId::Properties => {
self.show_properties = false;
self.ribbon.set_properties(false);
}
PanelId::Layers => {
self.show_layers = false;
self.ribbon.deactivate_tool_if("LAYERS");
}
PanelId::GraphicAttributes => {
self.show_graphic_attributes = false;
self.ribbon.set_graphic_attributes(false);
self.graphic_attributes.close_popups();
self.save_config();
}
}
self.dock_unpeek(id);
// A closed pallet cannot stay held by the pointer.
let held = match self.dock_drag {
Some(DockDrag::Move { panel, .. } | DockDrag::Width(panel)) => panel == id,
_ => false,
};
if held {
self.dock_drag = None;
}
task
}
/// Show `id` where the layout has it, first docking it on the right edge
/// when it has no place yet (opened for the first time, or never moved).
pub(crate) fn dock_open_at_default(&mut self, id: crate::ui::dock::PanelId) {
if !self.dock.is_placed(id) {
self.dock
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
self.dock_reveal(id);
}
/// Open the Layer Manager pallet (LAYERS). The first time it floats over
/// the middle of the drawing, where the dialog it replaced used to open;
/// after that it opens wherever the user left it.
pub(crate) fn open_layers_pallet(&mut self) {
use crate::ui::dock::{FloatPanel, PanelId};
self.sync_ribbon_layers();
self.show_layers = true;
if !self.dock.is_placed(PanelId::Layers) {
let (ww, wh) = self.dock_workspace_size();
let (w, h) = self.dock_float_size(PanelId::Layers);
let (x, y) = (((ww - w) * 0.5).max(0.0), ((wh - h) * 0.5).max(0.0));
self.dock
.float(FloatPanel::new(PanelId::Layers, x, y, w, h));
self.save_config();
}
self.dock.raise_float(PanelId::Layers);
self.dock_reveal(PanelId::Layers);
}
/// Stop revealing `id` (it closed).
pub(crate) fn dock_unpeek(&mut self, id: crate::ui::dock::PanelId) {
if self.dock_peek == Some(id) {
self.dock_peek = None;
}
}
/// Whether `id` is currently rendered (not closed, not on the start screen /
/// clean-screen viewport). Mirrors the visibility filter the edge column
/// renderer applies before building each side's stack.
pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool {
use crate::ui::dock::PanelId;
if self.tabs[self.active_tab].is_start || self.clean_screen {
return false;
}
match id {
PanelId::Properties => self.show_properties,
PanelId::BlockPalette => self.show_block_palette,
PanelId::ExternalReferences => self.show_external_references,
PanelId::Browser => self.show_browser,
PanelId::NodeGraph => self.show_node_graph,
PanelId::PointCloudManager => self.pc_manager.show,
PanelId::Count => self.count_palette.show,
PanelId::SheetSetManager => self.sheet_set.show,
PanelId::Layers => self.show_layers,
PanelId::GraphicAttributes => self.show_graphic_attributes,
}
}
/// Size of the workspace the dock lays out in: window width by viewport
/// height.
pub(crate) fn dock_workspace_size(&self) -> (f32, f32) {
(
self.win_size.0,
self.tabs[self.active_tab]
.scene
.selection
.borrow()
.view
.vp_size
.1,
)
}
/// The open pallets of group `gi` on `side`, top → bottom. Closed
/// pallets keep their place but take no space, so a group with none open
/// is not drawn at all.
pub(crate) fn dock_group_visible(
&self,
side: crate::app::config::DockSide,
gi: usize,
) -> Vec<crate::ui::dock::PanelId> {
// A stale index (a message from before the layout changed) reads as
// an empty group rather than a crash.
self.dock
.groups(side)
.get(gi)
.into_iter()
.flat_map(|g| g.panels.iter().copied())
.filter(|id| self.dock_panel_visible(*id))
.collect()
}
/// Indices of the groups on `side` with an open pallet (listed in the
/// icon strip).
pub(crate) fn dock_visible_groups(&self, side: crate::app::config::DockSide) -> Vec<usize> {
(0..self.dock.groups(side).len())
.filter(|gi| !self.dock_group_visible(side, *gi).is_empty())
.collect()
}
/// The group `side` draws: the one it shows, or the first with an open
/// pallet when that one has none.
pub(crate) fn dock_shown_group(&self, side: crate::app::config::DockSide) -> Option<usize> {
let visible = self.dock_visible_groups(side);
let shown = self.dock.shown(side);
if visible.contains(&shown) {
Some(shown)
} else {
visible.first().copied()
}
}
/// Whether `side` draws its shown group beside the icon strip: always,
/// unless the edge auto-hides and none of the group's icons is hovered.
pub(crate) fn dock_edge_expanded(&self, side: crate::app::config::DockSide) -> bool {
let Some(gi) = self.dock_shown_group(side) else {
return false;
};
!self.dock.edge_auto_hide(side)
|| self
.dock_peek
.is_some_and(|e| self.dock_group_visible(side, gi).contains(&e))
}
/// On-screen width of group `gi` on `side`.
pub(crate) fn dock_group_width(&self, side: crate::app::config::DockSide, gi: usize) -> f32 {
self.dock.group_width_px(side, gi, self.win_size.0)
}
/// Width of the pallet column beside `side`'s icon strip: the shown
/// group's width, or nothing when it is hidden.
pub(crate) fn dock_column_width(&self, side: crate::app::config::DockSide) -> f32 {
match self.dock_shown_group(side) {
Some(gi) if self.dock_edge_expanded(side) => self.dock_group_width(side, gi),
_ => 0.0,
}
}
/// The icon strip of `side`: each listed group's place, and the top of
/// the + button.
pub(crate) fn dock_strip_layout(
&self,
side: crate::app::config::DockSide,
) -> (Vec<crate::ui::dock::StripGroup>, f32) {
let groups: Vec<(usize, usize)> = self
.dock_visible_groups(side)
.into_iter()
.map(|gi| (gi, self.dock_group_visible(side, gi).len()))
.collect();
crate::ui::dock::strip_layout(&groups)
}
/// The open pallets of `side`'s shown group, each as (position in the
/// group, top, bottom) in the workspace.
pub(crate) fn dock_slot_spans(
&self,
side: crate::app::config::DockSide,
) -> Vec<(usize, f32, f32)> {
match self.dock_shown_group(side) {
Some(gi) => self.dock_open_spans(&self.dock.groups(side)[gi], None),
None => Vec::new(),
}
}
/// The open pallets of `group` (plus `extra`, a pallet about to land
/// there), each as (position in the group, top, bottom) down the
/// workspace height.
pub(crate) fn dock_open_spans(
&self,
group: &crate::ui::dock::DockGroup,
extra: Option<crate::ui::dock::PanelId>,
) -> Vec<(usize, f32, f32)> {
let (_, avail) = self.dock_workspace_size();
let open: Vec<usize> = (0..group.panels.len())
.filter(|i| {
let id = group.panels[*i];
Some(id) == extra || self.dock_panel_visible(id)
})
.collect();
let weights: Vec<f32> = open.iter().map(|i| group.weights[*i]).collect();
open.into_iter()
.zip(crate::ui::dock::slot_spans(&weights, avail))
.map(|(i, (top, bottom))| (i, top, bottom))
.collect()
}
/// Size a panel takes when floated: its current floating size, or its
/// docked width by a comfortable share of the workspace height.
pub(crate) fn dock_float_size(&self, id: crate::ui::dock::PanelId) -> (f32, f32) {
if let Some(f) = self.dock.float_rect(id) {
return (f.w, f.h);
}
let (_, avail) = self.dock_workspace_size();
(
self.dock.width(id, self.win_size.0),
(avail * 0.6).clamp(crate::ui::dock::FLOAT_MIN_H, 520.0),
)
}
/// Where a dragged pallet lands if released at workspace point `p`:
/// on an edge's icon strip it joins a group between its icons or starts a
/// new group; over the shown group's pallets it stacks above / below the
/// one under the pointer; on an empty edge it starts a group; anywhere
/// else it floats, held at `grab` from its top-left corner.
pub(crate) fn dock_drop_target(
&self,
p: iced::Point,
grab: iced::Vector,
) -> crate::ui::dock::DropTarget {
use crate::app::config::DockSide;
use crate::ui::dock::{DropTarget, StripHit, DOCK_EDGE_ZONE, DOCK_STRIP_W};
let (ww, _) = self.dock_workspace_size();
for side in [DockSide::Left, DockSide::Right] {
let listed = !self.dock_visible_groups(side).is_empty();
let from_edge = match side {
DockSide::Left => p.x,
DockSide::Right => ww - p.x,
};
if !listed {
if from_edge <= DOCK_EDGE_ZONE {
return DropTarget::Edge {
side,
index: self.dock.groups(side).len(),
};
}
continue;
}
// Over the icon strip.
if from_edge <= DOCK_STRIP_W {
let (layout, _) = self.dock_strip_layout(side);
return match crate::ui::dock::strip_hit(p.y, &layout) {
StripHit::NewGroup(at) => DropTarget::Edge {
side,
index: at.unwrap_or(self.dock.groups(side).len()),
},
StripHit::Join { group, index } => {
// Map the gap among open pallets to a position among
// all of the group's pallets.
let open = self.dock_group_visible(side, group);
let all = &self.dock.groups(side)[group].panels;
let index = open
.get(index)
.and_then(|t| all.iter().position(|a| a == t))
.unwrap_or(all.len());
DropTarget::Join { side, group, index }
}
};
}
// Over the shown group's pallets: above or below the one there.
if from_edge <= DOCK_STRIP_W + self.dock_column_width(side) {
let gi = self.dock_shown_group(side).expect("listed edge");
let spans = self.dock_slot_spans(side);
if let Some(&(pos, top, bottom)) = spans
.iter()
.find(|(_, _, bottom)| p.y < *bottom)
.or(spans.last())
{
let index = if p.y < (top + bottom) * 0.5 {
pos
} else {
pos + 1
};
return DropTarget::Join {
side,
group: gi,
index,
};
}
}
}
self.dock_float_target(p, grab)
}
/// Floating at workspace point `p`, held at `grab` from its top-left
/// corner (kept on the workspace).
pub(crate) fn dock_float_target(
&self,
p: iced::Point,
grab: iced::Vector,
) -> crate::ui::dock::DropTarget {
let (ww, wh) = self.dock_workspace_size();
let x = (p.x - grab.x).clamp(0.0, (ww - 60.0).max(0.0));
let y = (p.y - grab.y).clamp(0.0, (wh - 30.0).max(0.0));
crate::ui::dock::DropTarget::Float { x, y }
}
}
+1197
View File
File diff suppressed because it is too large. Load diff
+394 -21
View File
@@ -14,6 +14,7 @@ pub(crate) mod commands;
pub(crate) mod dim_viewport;
#[cfg(test)]
mod dimension_preview_tests;
mod dock;
mod document;
mod drafting_settings;
pub(crate) mod expr_eval;
@@ -814,6 +815,11 @@ pub(super) struct OpenCADStudio {
render_mode_preview: Option<codec::entities::ViewportRenderMode>,
/// Whether the Properties panel is shown on the left (PROPERTIES).
show_properties: bool,
/// The Layer Manager pallet (LAYERS) is open.
pub(crate) show_layers: bool,
/// Whether the Graphic Attributes palette is shown (GRAPHICATTRIBUTES).
show_graphic_attributes: bool,
graphic_attributes: crate::ui::window::graphic_attributes::GraphicAttributesState,
/// Docked Insert Block panel visibility.
pub(crate) show_block_palette: bool,
/// Node graph overlay over the viewport.
@@ -840,17 +846,33 @@ pub(super) struct OpenCADStudio {
pub(crate) sheet_set_import_parent: Option<String>,
/// General edge-stack dock layout for the side panels.
pub(crate) dock: crate::ui::dock::DockState,
/// Which panel is currently floated at full height (hovered, or a pinned
/// panel on top).
pub(crate) dock_expanded: Option<crate::ui::dock::PanelId>,
/// Panel currently being dragged between sides / reordered.
pub(crate) dock_dragging: Option<crate::ui::dock::PanelId>,
/// Panel currently being width-resized.
pub(crate) dock_resizing: Option<crate::ui::dock::PanelId>,
/// Last pointer position during a drag / resize.
/// Pallet revealed although it auto-hides (its edge's group, or its
/// floating frame): hovered, being dragged, or just opened. Cleared when
/// the pointer leaves.
pub(crate) dock_peek: Option<crate::ui::dock::PanelId>,
/// Reveal or hide waiting out the hover delay, with the generation its
/// timer carries; any newer hover supersedes it.
pub(crate) dock_hover_pending: Option<crate::ui::dock::HoverPending>,
pub(crate) dock_hover_gen: u64,
/// What the pointer is dragging in the dock (pallet, group, width,
/// splitter, floating size, Layer Manager column).
pub(crate) dock_drag: Option<crate::ui::dock::DockDrag>,
/// Pallet whose strip icon (or floating frame) the pointer is over.
pub(crate) dock_icon_hover: Option<crate::ui::dock::PanelId>,
/// Last pointer position during a dock drag.
pub(crate) dock_drag_last: Option<iced::Point>,
/// Live drag target (side + index), shown as a highlight while dragging.
pub(crate) dock_drag_target: Option<(crate::app::config::DockSide, usize)>,
/// Docked panel whose title bar the pointer is over (shows pin / close).
pub(crate) dock_title_hover: Option<crate::ui::dock::PanelId>,
/// Edge whose icon-strip pallet menu (+) is open.
pub(crate) dock_edge_menu: Option<crate::app::config::DockSide>,
/// Floating pallet whose title-bar right-click menu is open.
pub(crate) dock_float_menu: Option<crate::ui::dock::PanelId>,
/// Pointer position over a floating title bar (bar-local), and where
/// its open menu was asked for.
pub(crate) dock_float_pointer: iced::Point,
pub(crate) dock_float_menu_at: iced::Point,
/// Group whose edge band (group grip) the pointer is over.
pub(crate) dock_grip_hover: Option<(crate::app::config::DockSide, usize)>,
/// Reference-table column currently being width-resized (column index),
/// with the last pointer position. Mirrors the Layers Name-column drag.
pub(crate) xref_col_drag: Option<usize>,
@@ -890,6 +912,56 @@ pub(super) struct OpenCADStudio {
pending_native_thumbnail_save: Option<PendingNativeThumbnailSave>,
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: Option<PendingWebThumbnailSave>,
/// Web: whether the save in flight came from a plain Save
/// (Save/QSAVE or the save-before-close flow), so the tab's retained
/// File System Access handle (see io::fs_access) may be written
/// through directly; Save As opens the dialog modal and must re-pick.
/// Set true by `save_with_default_format`, false by
/// `open_save_dialog_window`.
#[cfg(target_arch = "wasm32")]
save_plain_reuse_fs_handle: bool,
/// One web autosave round is running; ticks that fire while it lasts are
/// dropped instead of queued (the next interval retries).
#[cfg(target_arch = "wasm32")]
web_autosave_in_flight: bool,
/// Web autosave storage already reported a failure — stay quiet until a
/// round succeeds again, so a full quota does not spam every tick.
#[cfg(target_arch = "wasm32")]
web_autosave_warned: bool,
/// Crash-recovery snapshots found at boot (newest first) that the restore
/// prompt still has to offer, one at a time.
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec<crate::io::web_autosave::WebAutosaveEntry>,
/// Snapshots a "Restore all" click still expects to settle (opened,
/// failed, cancelled, unreadable, or rejected by the open queue). Each
/// settle funnels through `queue_next_web_autosave_prompt`, which counts
/// this down; reaching zero arms `web_session_focus_pending`.
#[cfg(target_arch = "wasm32")]
web_autosave_restore_all_remaining: usize,
/// A Restore-all batch finished settling — apply the remembered
/// session's active tab at the next message boundary, after the last
/// restored open has actually inserted its tab (`web_autosave_opened`
/// runs before that insertion, so applying there would pick against a
/// tab list still missing the last restore).
#[cfg(target_arch = "wasm32")]
web_session_focus_pending: bool,
/// The tab list the previous session remembered (localStorage), loaded
/// after the boot autosave scan. Advisory only: it feeds the restore
/// modal's session line and the post-restore tab selection — nothing is
/// ever auto-opened from it.
#[cfg(target_arch = "wasm32")]
web_session: Option<crate::io::web_session::WebSession>,
/// The snapshot whose recovered open is in flight: `(autosave key,
/// open id)`. The OPFS copy is the only one, so it is deleted only
/// once the open with exactly that id succeeds — identity, never the
/// display name (same-named files share one stored copy on web).
#[cfg(target_arch = "wasm32")]
web_autosave_consumed: Option<(String, u64)>,
/// Autosave keys a web save wanted to remove while a snapshot round was
/// still writing (the round's late write would resurrect the snapshot
/// with a fresh mtime); the removes run when the round finishes.
#[cfg(target_arch = "wasm32")]
web_autosave_clear_after: Vec<String>,
// ── Floating panel windows ────────────────────────────────────────────
/// The open Select Color panel, if any.
color_picker: Option<crate::ui::color_select::ColorPicker>,
@@ -937,9 +1009,6 @@ pub(super) struct OpenCADStudio {
modal_drag_last: Option<Point>,
/// True while the modal title bar is held (a drag is in progress).
modal_dragging: bool,
/// Layer Manager: dragging the Name-column divider (width follows the
/// shared ModalDragMove flow).
layer_col_dragging: bool,
/// Layer Manager Name column width in px, adjusted by the divider drag.
layer_name_col_w: f32,
/// How far the user has dragged the modal's corner resize grip from the
@@ -1560,6 +1629,25 @@ pub(super) struct PendingExternalChange {
set_current_path: bool,
}
/// One tab's slot in a web SAVEALL round (`saveall_web`): the tab identity,
/// download name and race-guard markers snapshot when serialization started,
/// plus the writer result — the future fills `result` in before the
/// completion message is built. The web counterpart of native's
/// `SaveOutcome`, minus the path/continuation bookkeeping SAVEALL never had.
#[derive(Debug, Clone)]
#[cfg(target_arch = "wasm32")]
pub struct WebSaveallEntry {
tab_id: u64,
path: PathBuf,
filename: String,
ext: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
result: Result<Vec<u8>, String>,
}
#[derive(Debug, Clone)]
#[cfg(not(target_arch = "wasm32"))]
pub struct SaveOutcome {
@@ -1593,6 +1681,9 @@ pub enum UiColor {
/// Where a colour chosen in the standalone palette window should be applied.
#[derive(Debug, Clone)]
pub enum ColorPickTarget {
Gradient(u8),
GraphicAttributesLine,
GraphicAttributesSolid,
DimStyle(DsField),
MLeader(&'static str),
Table(u8, &'static str),
@@ -1946,7 +2037,6 @@ pub enum ModalKind {
PluginManager,
UpdateNotice,
DonationPrompt,
Layers,
LayerStateManager,
LayerTranslator,
DrawingUnits,
@@ -2014,6 +2104,11 @@ pub enum ModalKind {
XrefHelp,
/// Sheet set wizard, properties and sheet / subset forms.
SheetSet,
/// Web: a crash-recovery autosave snapshot newer than the drawing's last
/// save awaits a restore/discard decision. Distinct from the corrupt-file
/// `RecoveryPrompt` — nothing is wrong with the drawing itself.
#[cfg(target_arch = "wasm32")]
AutosaveRestore,
}
/// A property group controlled by a layer state's restore mask.
@@ -2135,6 +2230,7 @@ pub enum PasteFocus {
#[derive(Debug, Clone)]
pub enum Message {
GraphicAttributes(crate::ui::window::graphic_attributes::GraphicAttributesMsg),
SpaceMouseWake,
SpaceMouseFrame(iced::time::Instant),
SpaceMouseFocus(iced::window::Id, bool),
@@ -2153,6 +2249,10 @@ pub enum Message {
ControlStep(String, Box<Message>),
ControlTaskDone(String),
ControlScreenshot(String, Option<iced::window::Screenshot>),
/// A frame has been drawn (or the anti-hang timer expired) for a parked
/// `capture` request: take the screenshot now, so it shows the latest
/// state instead of the previous frame (C11).
ControlCaptureFrame { redrew: bool },
ControlToggle,
/// Node graph overlay interaction.
Graph(crate::ui::node_graph::GraphMsg),
@@ -2180,6 +2280,12 @@ pub enum Message {
PasteShortcutResolved(PasteFocus),
/// Completion of a system text clipboard read requested by PASTECLIP.
SystemClipboardPaste(SystemClipboardText),
/// Web: completion of the clipboard-image read the paste fallback starts
/// when no text was found — `Some((name, bytes))` attaches the image,
/// `None` reports the usual unsupported-content error. Native never
/// constructs it.
#[cfg(target_arch = "wasm32")]
SystemClipboardPasteImage(Option<(String, Vec<u8>)>),
/// Ctrl/Cmd+A — select all layer rows when the Layer Manager is open, or all
/// drawing objects otherwise (#236).
SelectAllShortcut,
@@ -2200,6 +2306,12 @@ pub enum Message {
OpenPathPicked(Option<(PathBuf, u64)>),
/// A file was dragged from the desktop and dropped on the window (#344).
FileDropped(PathBuf),
/// Web only: a drawing dragged onto the page — the DOM drop listener has
/// already read the bytes; this opens through the web-picker flow (#344).
/// The third field tags an autosave-restore file with its snapshot key so
/// the snapshot is deleted only when this exact open succeeds.
#[cfg(target_arch = "wasm32")]
WebFileDropped(String, Vec<u8>, Option<String>),
/// Web only: Ctrl+V captured by a focused text field, whose iced-internal
/// paste is inert there — read the async clipboard instead (#346).
WebFieldPaste,
@@ -2262,6 +2374,39 @@ pub enum Message {
/// Web: an asynchronous OPFS copy written after Save is ready for recents.
#[cfg(target_arch = "wasm32")]
WebRecentStored(Result<PathBuf, String>),
/// Web: the `?file=` boot deep link settled — its bytes entered the
/// guarded open queue, or a notice/error text for the command line
/// (recent copy missing, refused or failed fetch).
#[cfg(target_arch = "wasm32")]
WebLinkSettled(crate::io::web_link::LinkOutcome),
/// Web: the boot scan finished — these autosave snapshots are newer than
/// the drawings' last saved copies (newest first).
#[cfg(target_arch = "wasm32")]
WebAutosavesScanned(Result<Vec<crate::io::web_autosave::WebAutosaveEntry>, String>),
/// Web: restore the offered autosave snapshot into a recovered tab.
#[cfg(target_arch = "wasm32")]
WebAutosaveRestore,
/// Web: restore EVERY pending autosave snapshot — each is read and
/// enqueued through the guarded open queue with its own key (the poll
/// opens them sequentially); a snapshot is still consumed only after its
/// own open succeeds.
#[cfg(target_arch = "wasm32")]
WebAutosaveRestoreAll,
/// Web: delete the offered autosave snapshot (and offer the next, if any).
#[cfg(target_arch = "wasm32")]
WebAutosaveDiscard,
/// Web: close the restore prompt WITHOUT deleting the offered snapshot
/// (Escape/✕): it is simply re-offered on the next boot. Only the
/// Discard button deletes.
#[cfg(target_arch = "wasm32")]
WebAutosaveDefer,
/// Web: the offered snapshot's bytes are in; queue them for the guarded
/// open poll. The OPFS copy is consumed only once that open succeeds.
#[cfg(target_arch = "wasm32")]
WebAutosaveBytes(String, String, Result<Vec<u8>, String>),
/// Web: one autosave round finished for all dirty tabs (serially).
#[cfg(target_arch = "wasm32")]
WebAutosaveSnapshotFinished(Result<(), String>),
SaveFile,
SaveAs,
// ── Custom Save-As dialog ─────────────────────────────────────────────
@@ -2439,6 +2584,19 @@ pub enum Message {
LayerTranslatorLoad,
/// A target set was read from `path`.
LayerTranslatorLoaded(std::path::PathBuf),
/// Web: a standards drawing picked and parsed in the browser, with the
/// target layers already extracted — a name-only path has nothing behind
/// it for `load_targets` to read. `Ok(None)` = cancelled.
#[cfg(target_arch = "wasm32")]
LayerTranslatorLoadedWeb(
Result<
Option<(
String,
Vec<crate::modules::draw::layers::laytrans::TargetLayer>,
)>,
String,
>,
),
LayerTranslatorSelectFrom(String),
LayerTranslatorSelectTo(String),
/// Pair the two selected layers.
@@ -2544,6 +2702,89 @@ pub enum Message {
bounds: Option<iced::Rectangle>,
screenshot: Option<iced::window::Screenshot>,
},
/// Web SAVE: the serializer worker (or the inline fallback) produced the
/// drawing bytes; download them and finish the save bookkeeping. The
/// epoch/revision/camera markers snapshot the tab when serialization
/// started — the web counterpart of native's `snapshot_is_current`, so a
/// save that raced user edits does not wipe the newer dirty flag.
#[cfg(target_arch = "wasm32")]
WebSaveSerialized {
tab_id: u64,
filename: String,
autosave_key: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
result: Result<Vec<u8>, String>,
},
/// Web SAVE with the File System Access API: the showSaveFilePicker
/// promise settled. The picker ran in the save-confirm handler — the
/// user-gesture moment — because the serialization that follows may
/// outlast the transient-activation window; the retained handle (kept
/// in io::fs_access's tab-keyed registry, since a JsValue handle cannot
/// ride in a Send Message) is written to later without needing a
/// gesture. `Ok(Some(name))` = destination chosen (`name` is its real,
/// possibly renamed name); `Ok(None)` = the user dismissed the picker
/// (cancel the save, like desktop); `Err` = the picker itself failed
/// (fall back to the legacy download).
#[cfg(target_arch = "wasm32")]
WebSaveHandlePicked {
tab_id: u64,
filename: String,
ext: String,
version: codec::DxfVersion,
picked: Result<Option<String>, String>,
},
/// Web SAVE in-place completion: the retained FS Access handle's
/// createWritable write settled (on failure the task already fell back
/// to a download); run the shared save bookkeeping, including the OPFS
/// recent-copy store the download path performs. Same race-guard
/// markers as `WebSaveSerialized`.
#[cfg(target_arch = "wasm32")]
WebSaveWritten {
tab_id: u64,
filename: String,
autosave_key: String,
version: codec::DxfVersion,
geometry_epoch: u64,
edit_revision: u64,
camera_generation: u64,
result: Result<(), String>,
bytes: Vec<u8>,
},
/// Web WBLOCK: the serializer worker (or the inline fallback) produced
/// the block document bytes; download them under `filename`, then run
/// the same command-line bookkeeping as `WblockWriteFinished`.
#[cfg(target_arch = "wasm32")]
WebWblockSerialized {
block_name: String,
path: std::path::PathBuf,
filename: String,
result: Result<Vec<u8>, String>,
},
/// Web SAVEALL round completion: one entry per tab serialized on the
/// worker (or the inline fallback). Downloads fire per entry; dirty
/// flags clear only for tabs whose serialized snapshot is still current
/// (the same race guard a single web Save uses).
#[cfg(target_arch = "wasm32")]
WebSaveallSerialized {
skipped: usize,
results: Vec<WebSaveallEntry>,
},
/// Web ARCHIVE/ETRANSMIT completion: the serializer worker (or the
/// inline fallback) produced the drawing bytes; assemble the transmittal
/// ZIP from the dependencies collected at command time, download it and
/// report — including the skipped-dependency lines.
#[cfg(target_arch = "wasm32")]
WebArchiveSerialized {
cmd: String,
zip_name: String,
filename: String,
deps: Vec<(String, Vec<u8>)>,
skipped: Vec<String>,
result: Result<Vec<u8>, String>,
},
/// Retry the failed save after the other application releases the file.
#[cfg(not(target_arch = "wasm32"))]
SaveFileInUseRetry,
@@ -2573,6 +2814,19 @@ pub enum Message {
/// dispatch, this accepts an interactive verb and all of its arguments on
/// the same line (`BOX 0,0,0 10,10,0 10`).
ScriptLine(String),
/// Web SCRIPT: the picked .scr file's name and text. `None` = the
/// browser dialog was cancelled.
#[cfg(target_arch = "wasm32")]
ScriptFilePicked(Option<(String, String)>),
/// Web CENTERLTYPEFILE: the picked .lin/.dnl file's name and text.
/// `None` = cancelled.
#[cfg(target_arch = "wasm32")]
CenterlineLinetypeFilePicked(Option<(String, String)>),
/// Web DATALINK: the picked .csv file's name and raw bytes — registered
/// in the picked-source registry, then fed to the same table-insert path
/// the native typed-path command uses. `None` = cancelled.
#[cfg(target_arch = "wasm32")]
DataLinkCsvPicked(Option<(String, Vec<u8>)>),
/// Append one typed character to the command-line input from the
/// global key-press subscription. Used when the text-input widget
/// itself isn't focused (focus parked on viewport / button / etc.)
@@ -2603,6 +2857,12 @@ pub enum Message {
shortcut: String,
extend_selection: bool,
},
/// A text field captured SPACE while it is rebound away from
/// COMMANDSPACE; run the binding only if the command input has focus.
CommandLineSpaceProbe,
/// The command input has focus: drop the space it just typed and run the
/// SPACE binding.
CommandLineSpaceResolved,
/// A widget captured Up/Down; resolve it only if the command input owns
/// keyboard focus.
CommandLineArrowProbe {
@@ -2856,6 +3116,8 @@ pub enum Message {
ToggleViewCube,
/// Toggle the Properties panel visibility (PROPERTIES).
ToggleProperties,
/// Toggle the Graphic Attributes panel visibility (GRAPHICATTRIBUTES).
ToggleGraphicAttributes,
/// Toggle the document file tabs at the top (FILETAB).
ToggleFileTabs,
/// Toggle the layout tabs at the bottom (LAYOUTTAB).
@@ -3667,6 +3929,8 @@ pub enum Message {
/// Send current layout to the system printer (via lp / lpr).
PrintToPrinter,
/// Callback from the async printer job.
/// Web never constructs it — printing opens the PDF preview instead.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
PrintResult(Result<String, String>),
/// Open the full Plot / Print dialog (seeds state from the layout).
PlotDialogOpen,
@@ -4001,7 +4265,9 @@ pub enum Message {
DataExtractionBrowseSettings,
DataExtractionBrowseSettingsResult(Option<std::path::PathBuf>),
DataExtractionAddDrawings,
DataExtractionAddDrawingsResult(Vec<std::path::PathBuf>),
/// Picked source drawings — `Err` carries a pick/parse failure (web
/// parses each file on the worker at pick time).
DataExtractionAddDrawingsResult(Result<Vec<std::path::PathBuf>, String>),
DataExtractionAddFolder,
DataExtractionAddFolderResult(Option<std::path::PathBuf>),
DataExtractionClearSources,
@@ -4028,6 +4294,8 @@ pub enum Message {
/// Trigger OBJ import: show open-file dialog.
ObjImport,
/// Callback after the user picks (or cancels) the OBJ file path.
/// Web never constructs it — the browser handle's bytes are read directly.
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
ObjImportPath(Option<std::path::PathBuf>),
ObjImportFinished(
u64,
@@ -4270,6 +4538,9 @@ impl OpenCADStudio {
render_mode_menu_open: false,
render_mode_preview: None,
show_properties: true,
show_layers: false,
show_graphic_attributes: false,
graphic_attributes: Default::default(),
show_block_palette: false,
show_node_graph: false,
property_target_override: None,
@@ -4284,11 +4555,18 @@ impl OpenCADStudio {
block_palette: Default::default(),
xref_manager: Default::default(),
dock: Default::default(),
dock_expanded: None,
dock_dragging: None,
dock_resizing: None,
dock_peek: None,
dock_hover_pending: None,
dock_hover_gen: 0,
dock_float_menu: None,
dock_float_pointer: iced::Point::ORIGIN,
dock_float_menu_at: iced::Point::ORIGIN,
dock_drag: None,
dock_drag_last: None,
dock_drag_target: None,
dock_title_hover: None,
dock_edge_menu: None,
dock_grip_hover: None,
dock_icon_hover: None,
xref_col_drag: None,
xref_col_last: None,
xref_split_drag: false,
@@ -4304,6 +4582,24 @@ impl OpenCADStudio {
pending_native_thumbnail_save: None,
#[cfg(target_arch = "wasm32")]
pending_web_thumbnail_save: None,
#[cfg(target_arch = "wasm32")]
save_plain_reuse_fs_handle: false,
#[cfg(target_arch = "wasm32")]
web_autosave_in_flight: false,
#[cfg(target_arch = "wasm32")]
web_autosave_warned: false,
#[cfg(target_arch = "wasm32")]
web_autosave_pending: Vec::new(),
#[cfg(target_arch = "wasm32")]
web_autosave_restore_all_remaining: 0,
#[cfg(target_arch = "wasm32")]
web_session_focus_pending: false,
#[cfg(target_arch = "wasm32")]
web_session: None,
#[cfg(target_arch = "wasm32")]
web_autosave_consumed: None,
#[cfg(target_arch = "wasm32")]
web_autosave_clear_after: Vec::new(),
color_picker: None,
recent_colors: Vec::new(),
active_modal: None,
@@ -4324,7 +4620,6 @@ impl OpenCADStudio {
modal_offset: iced::Vector::ZERO,
modal_drag_last: None,
modal_dragging: false,
layer_col_dragging: false,
layer_name_col_w: 130.0,
modal_resize: iced::Vector::ZERO,
modal_content_size: None,
@@ -4861,6 +5156,16 @@ impl OpenCADStudio {
fn boot_web() -> (Self, Task<Message>) {
#[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
let mut s = Self::new();
// A web host that intends to automate can opt in before the first
// control request by setting `opencadstudio.automation` to "1" in
// localStorage (the same pre-load mechanism the settings use): the
// startup donation prompt then never queues (#1349).
if web_sys::window().and_then(|window| window.local_storage().ok().flatten()).and_then(
|storage| storage.get_item("opencadstudio.automation").ok().flatten(),
) == Some("1".to_string())
{
s.control.automation_seen = true;
}
s.queue_startup_prompts();
let focus = s.focus_cmd_input();
let primary_font =
@@ -4880,9 +5185,64 @@ impl OpenCADStudio {
Message::DiscussionsFetched,
);
let thumbs_fetch = s.refresh_recent_thumbs();
// Crash-recovery scan: OPFS snapshots newer than a drawing's last
// saved copy come back as a restore prompt (stale ones are deleted).
let autosave_scan = Task::perform(
crate::io::web_autosave::scan(),
Message::WebAutosavesScanned,
);
// URL deep link (`?file=…`): a shareable/embeddable link that opens a
// drawing at boot. An http(s) URL is fetched with the same capped
// streaming reader the remote-image fetch uses and handed to the
// guarded drop-open queue; a bare name reads the OPFS recent copy the
// same way `open_recent_web` does. Everything else (other schemes,
// non-.dwg/.dxf URL tails) is refused with a console warning — the
// same scheme gate the image fetch applies. Pure parsing/validation
// lives in `io::web_link` and is unit-tested there.
let deep_link: Task<Message> = {
let search = web_sys::window()
.and_then(|window| window.location().search().ok())
.unwrap_or_default();
match crate::io::web_link::parse_link(&search) {
crate::io::web_link::ParsedLink::Target(target) => {
// Opening a URL from the query string is an implicit trust
// decision: name the origin before the fetch starts (info
// only — the bytes still go through the parse + recovery
// machinery like any other open). Same-origin links stay
// quiet: that drawing already comes from this site.
if let crate::io::web_link::LinkTarget::Url { url, name } = &target {
if !crate::io::web_link::same_origin(url) {
let origin = crate::io::web_link::display_origin(url)
.unwrap_or_else(|| url.clone());
s.command_line
.push_info(&format!("Opening {name} from {origin}…"));
}
}
Task::perform(crate::io::web_link::open_link(target), |outcome| {
Message::WebLinkSettled(outcome)
})
}
crate::io::web_link::ParsedLink::Ignored(refusal, value) => {
web_sys::console::warn_1(&wasm_bindgen::JsValue::from_str(
&crate::io::web_link::refusal_warning(refusal, &value),
));
Task::none()
}
crate::io::web_link::ParsedLink::Absent => Task::none(),
}
};
(
s,
Task::batch([focus, fonts, patrons, videos, discussions, thumbs_fetch]),
Task::batch([
focus,
fonts,
patrons,
videos,
discussions,
thumbs_fetch,
autosave_scan,
deep_link,
]),
)
}
}
@@ -4940,6 +5300,19 @@ impl Drop for OpenCADStudio {
/// unavailable for now (see issue #45). Native keeps the multi-window `run`.
#[cfg(target_arch = "wasm32")]
pub fn run_web() -> iced::Result {
// winit never emits DroppedFile on the web, so the document drag & drop
// listeners are installed here and feed the control poll (#344).
crate::io::web_drop::install();
// Best-effort, fire-and-forget: ask the browser not to evict the origin's
// storage under pressure — settings (localStorage) and the OPFS recent
// copies / autosave snapshots live there. A denial changes nothing.
wasm_bindgen_futures::spawn_local(async {
if let Some(window) = web_sys::window() {
if let Ok(promise) = window.navigator().storage().persist() {
let _ = wasm_bindgen_futures::JsFuture::from(promise).await;
}
}
});
iced::application(
OpenCADStudio::boot_web,
OpenCADStudio::update,
+1 -5
View File
@@ -188,11 +188,7 @@ impl OpenCADStudio {
// The node library docks on the right on first use; after
// that it stays wherever the user moved it.
let id = crate::ui::dock::PanelId::NodeGraph;
if self.dock.location(id).is_none() {
self.dock
.dock(id, crate::app::config::DockSide::Right, usize::MAX);
}
self.dock_expanded = Some(id);
self.dock_open_at_default(id);
return self.graph_run(i);
}
}
+867 -64
View File
@@ -2337,80 +2337,77 @@ impl<'a> HostSession<'a> {
Ok(handle)
}
TableOperation::BlockRename { from, to } => {
let (from, handle) = editable_block(self.document(), from.trim())?;
let to = validated_symbol_name(&to, "a block name")?;
if to.starts_with('*') {
return Err("a block name cannot start with '*'".to_owned());
let from = from.trim().to_owned();
let to = to.trim().to_owned();
let open_edits = self.open_block_edits();
let handle = self
.document()
.block_records
.get(&from)
.map(|record| record.handle)
.unwrap_or(Handle::NULL);
self.push_undo(&format!("Rename block {from} to {to}"));
match crate::scene::block_service::rename(
&mut self.app.tabs[self.tab].scene,
&from,
&to,
&open_edits,
) {
Ok(_) => {
self.finish_block_change();
Ok(handle)
}
Err(error) => {
self.app.discard_last_undo_entry(self.tab);
Err(error.to_string())
}
}
if from.eq_ignore_ascii_case(&to) {
return Err("the new block name matches the current one (a case-only change is not supported)".to_owned());
}
if self.document().block_records.get(&to).is_some() {
return Err(format!("block {to:?} already exists"));
}
self.push_undo("Rename block");
if !self.app.tabs[self.tab].scene.rename_block(&from, &to) {
return Err("the block could not be renamed".to_owned());
}
self.finish_block_change();
Ok(handle)
}
TableOperation::BlockDelete { name } => {
let (name, handle) = editable_block(self.document(), name.trim())?;
let doc = self.document();
let referenced = doc.entities().any(|entity| match entity {
E::Insert(insert) => insert.block_name.eq_ignore_ascii_case(&name),
E::MultiLeader(leader) => {
leader.block_content_handle == Some(handle)
|| leader.context.block_content_handle == Some(handle)
let name = name.trim().to_owned();
// The external API has no force flag, so a referenced block is
// always refused here — same rule as the palette.
let open_edits = self.open_block_edits();
let handle = self
.document()
.block_records
.get(&name)
.map(|record| record.handle)
.unwrap_or(Handle::NULL);
self.push_undo(&format!("Delete block {name}"));
match crate::scene::block_service::delete(
&mut self.app.tabs[self.tab].scene,
&name,
crate::scene::block_service::DeleteOptions {
force: false,
open_edits: &open_edits,
},
) {
Ok(_) => {
self.finish_block_change();
Ok(handle)
}
Err(error) => {
self.app.discard_last_undo_entry(self.tab);
Err(error.to_string())
}
_ => false,
}) || doc.dim_styles.iter().any(|style| {
[style.dimblk, style.dimblk1, style.dimblk2, style.dimldrblk].contains(&handle)
});
if referenced {
return Err(format!(
"block {name:?} is still inserted or used by a style or leader"
));
}
let members: Vec<Handle> = {
let record = doc.block_records.get(&name).expect("checked above");
let mut members = record.entity_handles.clone();
members.push(record.block_entity_handle);
members.push(record.block_end_handle);
members
};
self.push_undo("Delete block");
let doc = self.document_mut();
for member in members {
doc.remove_entity(member);
}
doc.block_records.remove(&name);
let orphans: Vec<Handle> = {
let live: std::collections::HashSet<Handle> =
doc.block_records.iter().map(|r| r.handle).collect();
doc.objects
.iter()
.filter_map(|(h, object)| match object {
codec::objects::ObjectType::SortEntitiesTable(table)
if !live.contains(&table.block_owner_handle) =>
{
Some(*h)
}
_ => None,
})
.collect()
};
for orphan in orphans {
doc.objects.remove(&orphan);
}
self.finish_block_change();
Ok(handle)
}
_ => Err("unsupported table operation".to_owned()),
}
}
/// Block records this document has open in a BEDIT session. Nested
/// sessions all count, so the whole stack is handed to the block service,
/// which refuses delete/rename and skips PURGE for every one of them.
fn open_block_edits(&self) -> Vec<Handle> {
self.app.tabs[self.tab]
.block_edits
.iter()
.map(|session| session.br_handle)
.collect()
}
fn finish_block_change(&mut self) {
self.app.tabs[self.tab].dirty = true;
self.app.tabs[self.tab].scene.invalidate_dependency_index();
@@ -2777,6 +2774,364 @@ impl<'a> HostSession<'a> {
self.publish_document_view();
true
}
// ── XRecord objects (API v8, additive) ────────────────────────────────────
//
// XRecords are non-graphical objects: name-addressed typed group-code/value
// pairs stored under the drawing's named-object dictionary, where a
// drawing keeps application data. They give scripts a record carrier that
// lives in the file and survives model view entirely, with no carrier
// entity.
/// The dictionary addressed by `dictionary_path` under the named-object
/// dictionary root (an empty path is the root itself). Nothing is created:
/// a missing fragment is an error, and so is a fragment that exists but
/// names a non-dictionary object.
fn xrecord_dictionary_at(&self, dictionary_path: &[&str]) -> Result<Handle, String> {
use codec::objects::ObjectType;
let root = self.document().header.named_objects_dict_handle;
if !matches!(
self.document().objects.get(&root),
Some(ObjectType::Dictionary(_))
) {
return Err("the drawing has no named-object dictionary".to_owned());
}
let mut current = root;
for fragment in dictionary_path {
let name = validated_symbol_name(fragment, "a dictionary name")?;
match self.document().objects.get(&current) {
Some(ObjectType::Dictionary(dictionary)) => match dictionary.get(&name) {
Some(child) => {
if !matches!(
self.document().objects.get(&child),
Some(ObjectType::Dictionary(_))
) {
return Err(format!("dictionary entry {name:?} is not a dictionary"));
}
current = child;
}
None => return Err(format!("dictionary {name:?} does not exist")),
},
_ => return Err(format!("dictionary {current:?} does not exist")),
}
}
Ok(current)
}
/// The deepest ancestor of `dictionary_path` that already exists, and how
/// deep the path resolved. Errors when a fragment that exists names a
/// non-dictionary object. Creation calls this first so its refusals happen
/// before the undo step is recorded.
fn deepest_existing_xrecord_dictionary(
&self,
dictionary_path: &[&str],
) -> Result<(Handle, usize), String> {
use codec::objects::ObjectType;
let root = self.document().header.named_objects_dict_handle;
if !matches!(
self.document().objects.get(&root),
Some(ObjectType::Dictionary(_))
) {
return Err("the drawing has no named-object dictionary".to_owned());
}
let mut current = root;
let mut depth = 0usize;
for fragment in dictionary_path {
let name = validated_symbol_name(fragment, "a dictionary name")?;
match self.document().objects.get(&current) {
Some(ObjectType::Dictionary(dictionary)) => match dictionary.get(&name) {
Some(child) => {
if !matches!(
self.document().objects.get(&child),
Some(ObjectType::Dictionary(_))
) {
return Err(format!("dictionary entry {name:?} is not a dictionary"));
}
current = child;
depth += 1;
}
None => break,
},
_ => return Err(format!("dictionary {current:?} does not exist")),
}
}
Ok((current, depth))
}
/// Walk `dictionary_path` from the named-object dictionary root, creating
/// missing dictionaries on the way — the same bootstrap
/// `ensure_image_dictionary` uses for ACAD_IMAGE_DICT: each new dictionary
/// owns its handle, is owned by its parent and is registered in the parent
/// under its name. Mutates; only valid after the caller validated the path
/// and recorded its undo step.
fn xrecord_dictionary_at_or_create(
&mut self,
dictionary_path: &[&str],
) -> Result<Handle, String> {
use codec::objects::{Dictionary, ObjectType};
let root = self.document().header.named_objects_dict_handle;
if !matches!(
self.document().objects.get(&root),
Some(ObjectType::Dictionary(_))
) {
return Err("the drawing has no named-object dictionary".to_owned());
}
let mut current = root;
for fragment in dictionary_path {
let name = validated_symbol_name(fragment, "a dictionary name")?;
let existing = match self.document().objects.get(&current) {
Some(ObjectType::Dictionary(dictionary)) => dictionary.get(&name),
_ => return Err(format!("dictionary {current:?} does not exist")),
};
match existing {
Some(child) => {
if !matches!(
self.document().objects.get(&child),
Some(ObjectType::Dictionary(_))
) {
return Err(format!("dictionary entry {name:?} is not a dictionary"));
}
current = child;
}
None => {
let child = self.document_mut().allocate_handle();
let mut dictionary = Dictionary::new();
dictionary.handle = child;
dictionary.owner = current;
self.document_mut()
.objects
.insert(child, ObjectType::Dictionary(dictionary));
if let Some(ObjectType::Dictionary(entries)) =
self.document_mut().objects.get_mut(&current)
{
entries.add_entry(name, child);
}
current = child;
}
}
}
Ok(current)
}
/// Undo composition for XRecord writes. Plugins may open one `push_undo`
/// scope for a whole operation — the same contract entity writes follow
/// (`add_entity` and friends perform no undo bookkeeping of their own) —
/// and every XRecord mutation inside it joins that open scope, so the
/// finished scope is a single undo step under the plugin's own label that
/// restores (and redoes) the whole operation at once. Only a mutation with
/// no scope open opens its own — and then the caller finishes it again,
/// keeping a scope-less call one undo step (the `table_operation` /
/// `update_entities_transaction` behavior). Refusals never reach this:
/// they are decided before any change. Returns whether this call opened
/// its own scope.
fn ensure_xrecord_undo_scope(&mut self, label: &str) -> bool {
if self.app.tabs[self.tab].history.pending.is_none() {
self.push_undo(label);
true
} else {
false
}
}
/// Create an XRecord named `name` under `dictionary_path`, storing
/// `entries` (API v8, additive). Missing dictionaries on the path are
/// created, so the first call seeds a plugin's namespace; the name must be
/// unused (dictionary lookup is case-insensitive). Every refusal happens
/// before the undo scope is touched, so a failure changes nothing. The
/// created record is registered in — and owned by — its dictionary, and
/// the new handle is returned.
///
/// Undo composition: a mutation inside a plugin-opened `push_undo` scope
/// joins it (see [`HostSession::ensure_xrecord_undo_scope`]); a scope-less
/// call is one undo step of its own.
pub fn create_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<codec::objects::XRecordEntry>,
) -> Result<Handle, String> {
use codec::objects::{ObjectType, XRecord};
let name = validated_symbol_name(name, "an XRecord name")?;
validated_xrecord_entries(&entries)?;
// Pre-check the path: fragments that already exist must be
// dictionaries, and when the full path exists the name must be free —
// create must never silently overwrite a key.
let (deepest, depth) = self.deepest_existing_xrecord_dictionary(dictionary_path)?;
if depth == dictionary_path.len() {
if let Some(ObjectType::Dictionary(dictionary)) = self.document().objects.get(&deepest)
{
if dictionary.get(&name).is_some() {
return Err(format!("XRecord {name:?} already exists"));
}
}
}
let own_scope = self.ensure_xrecord_undo_scope("Create XRecord");
// The pre-check ruled out every failure mode; the walk now only creates
// the dictionary fragments that were missing.
let dictionary = self
.xrecord_dictionary_at_or_create(dictionary_path)
.expect("validated dictionary path");
let handle = self.document_mut().allocate_handle();
let mut record = XRecord::named(name.clone());
record.handle = handle;
record.owner = dictionary;
record.entries = entries;
record.synchronize_object_references();
self.document_mut()
.objects
.insert(handle, ObjectType::XRecord(record));
if let Some(ObjectType::Dictionary(entries)) =
self.document_mut().objects.get_mut(&dictionary)
{
entries.add_entry(name, handle);
}
self.set_dirty();
self.publish_document_view();
if own_scope {
self.app.finish_pending_history(self.tab);
}
Ok(handle)
}
/// Read the entries of an existing XRecord by name (API v8, additive).
/// The entries are returned verbatim; the path, the name and the target
/// kind are validated first, so a refusal changes nothing.
pub fn read_xrecord(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<Vec<codec::objects::XRecordEntry>, String> {
use codec::objects::ObjectType;
let (_, _, handle) = self.xrecord_lookup(dictionary_path, name)?;
match self.document().objects.get(&handle) {
Some(ObjectType::XRecord(record)) => Ok(record.entries.clone()),
_ => unreachable!("validated XRecord"),
}
}
/// Atomically replace the entries of an existing XRecord (API v8,
/// additive). Neither the path nor its dictionaries are created — an
/// update must never create anything silently. Identity (handle, owning
/// dictionary, name) stays; the entries and their DWG handle-reference
/// stream are replaced and the record's verbatim raw payloads are dropped,
/// so later saves emit the new content. Every refusal happens before the
/// undo scope is touched. Undo composition: a mutation inside a
/// plugin-opened `push_undo` scope joins it (see
/// [`HostSession::ensure_xrecord_undo_scope`]); a scope-less call is one
/// undo step of its own.
pub fn update_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<codec::objects::XRecordEntry>,
) -> Result<Handle, String> {
use codec::objects::ObjectType;
validated_xrecord_entries(&entries)?;
let (_, _, handle) = self.xrecord_lookup(dictionary_path, name)?;
let own_scope = self.ensure_xrecord_undo_scope("Update XRecord");
let record = match self.document_mut().objects.get_mut(&handle) {
Some(ObjectType::XRecord(record)) => record,
_ => unreachable!("validated XRecord"),
};
record.entries = entries;
// Rebuild the DWG object-reference stream from the new 330-369 entries
// and drop the verbatim bytes an earlier load kept: the update fully
// defines the record now.
record.synchronize_object_references();
record.preserve_object_reference_stream = false;
record.entries_complete = true;
record.raw_data.clear();
record.raw_dwg_data = None;
record.raw_dwg_handle_bits = 0;
record.raw_dwg_version = None;
self.set_dirty();
self.publish_document_view();
if own_scope {
self.app.finish_pending_history(self.tab);
}
Ok(handle)
}
/// Delete the XRecord named `name` and its dictionary entry (API v8,
/// additive). Returns the removed record with its prior content, so audits
/// keep what they removed. Every refusal happens before the undo scope is
/// touched. Undo composition: a mutation inside a plugin-opened
/// `push_undo` scope joins it (see
/// [`HostSession::ensure_xrecord_undo_scope`]); a scope-less call is one
/// undo step of its own.
pub fn delete_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
) -> Result<codec::objects::XRecord, String> {
use codec::objects::ObjectType;
let (dictionary, name, handle) = self.xrecord_lookup(dictionary_path, name)?;
let own_scope = self.ensure_xrecord_undo_scope("Delete XRecord");
let removed = match self.document_mut().objects.remove(&handle) {
Some(ObjectType::XRecord(record)) => record,
_ => unreachable!("validated XRecord"),
};
if let Some(ObjectType::Dictionary(entries)) =
self.document_mut().objects.get_mut(&dictionary)
{
entries
.entries
.retain(|(key, value)| !key.eq_ignore_ascii_case(&name) || *value != handle);
}
self.set_dirty();
self.publish_document_view();
if own_scope {
self.app.finish_pending_history(self.tab);
}
Ok(removed)
}
/// List the XRecord names under the dictionary addressed by
/// `dictionary_path` (API v8, additive): the dictionary keys that address
/// XRecord objects, in the dictionary's own order, so consumers can audit
/// and export without knowing names in advance. Non-XRecord entries are
/// not listed.
pub fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
use codec::objects::ObjectType;
let dictionary = self.xrecord_dictionary_at(dictionary_path)?;
let Some(ObjectType::Dictionary(entries)) = self.document().objects.get(&dictionary) else {
return Err(format!("dictionary {dictionary:?} does not exist"));
};
Ok(entries
.entries
.iter()
.filter_map(|(key, handle)| match self.document().objects.get(handle) {
Some(ObjectType::XRecord(_)) => Some(key.clone()),
_ => None,
})
.collect())
}
/// Resolve an existing XRecord to its containing dictionary, its validated
/// name and its handle, after validating the path, the name and the target
/// kind.
fn xrecord_lookup(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<(Handle, String, Handle), String> {
use codec::objects::ObjectType;
let dictionary = self.xrecord_dictionary_at(dictionary_path)?;
let name = validated_symbol_name(name, "an XRecord name")?;
let Some(ObjectType::Dictionary(entries)) = self.document().objects.get(&dictionary) else {
return Err(format!("dictionary {dictionary:?} does not exist"));
};
let Some(handle) = entries.get(&name) else {
return Err(format!("XRecord {name:?} does not exist"));
};
if !matches!(
self.document().objects.get(&handle),
Some(ObjectType::XRecord(_))
) {
return Err(format!("dictionary entry {name:?} is not an XRecord"));
}
Ok((dictionary, name, handle))
}
}
/// True when block `outer` (transitively) contains an insert of `target`.
@@ -2824,6 +3179,28 @@ fn validated_symbol_name(raw: &str, what: &str) -> Result<String, String> {
Ok(name.to_owned())
}
/// Validate the entries an XRecord create or update wants to store: each needs
/// a group code the codec model admits (1..=369; 5 and 105 are reserved for
/// object handles). Refused before any change so bad input never reaches a
/// file.
fn validated_xrecord_entries(entries: &[codec::objects::XRecordEntry]) -> Result<(), String> {
for entry in entries {
if !(1..=369).contains(&entry.code) {
return Err(format!(
"XRecord group code {} is out of range 1..=369",
entry.code
));
}
if entry.code == 5 || entry.code == 105 {
return Err(format!(
"XRecord group code {} is reserved for the object handle",
entry.code
));
}
}
Ok(())
}
fn apply_text_style_config(
style: &mut codec::tables::TextStyle,
config: &ocs_plugin_api::host::TextStyleConfig,
@@ -3100,6 +3477,39 @@ impl HostApi for HostSession<'_> {
) -> Result<ocs_plugin_api::host::CommandOutcome, String> {
self.run_command(request)
}
fn create_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<ocs_plugin_api::host::XRecordEntry>,
) -> Result<Handle, String> {
self.create_xrecord(dictionary_path, name, entries)
}
fn read_xrecord(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<Vec<ocs_plugin_api::host::XRecordEntry>, String> {
self.read_xrecord(dictionary_path, name)
}
fn update_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<ocs_plugin_api::host::XRecordEntry>,
) -> Result<Handle, String> {
self.update_xrecord(dictionary_path, name, entries)
}
fn delete_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
) -> Result<ocs_plugin_api::host::XRecord, String> {
self.delete_xrecord(dictionary_path, name)
}
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
self.list_xrecords(dictionary_path)
}
}
/// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand)
@@ -3600,6 +4010,399 @@ mod tests {
);
}
#[test]
fn xrecord_crud_round_trips_refusals_and_list() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let entries = || {
vec![
codec::objects::XRecordEntry::string(1, "{\"k\":\"road\",\"s\":1}"),
codec::objects::XRecordEntry::double(40, 0.125),
codec::objects::XRecordEntry::int16(70, 3),
codec::objects::XRecordEntry::int32(90, -45000),
codec::objects::XRecordEntry::bool(290, true),
]
};
// Create seeds the namespace: the dictionary chain is auto-created and
// reading returns the entries verbatim.
let alpha = host.create_xrecord(&["CVIFC"], "alpha", entries()).unwrap();
assert!(!alpha.is_null());
assert_eq!(host.read_xrecord(&["CVIFC"], "alpha").unwrap(), entries());
// Dictionary lookup is case-insensitive, both for the path …
assert_eq!(host.read_xrecord(&["cvifc"], "alpha").unwrap(), entries());
// … and re-creating an existing name is refused without any change.
assert!(host
.create_xrecord(&["cvifc"], "ALPHA", entries())
.unwrap_err()
.contains("already exists"));
// Namespace reuse is idempotent: the root still holds one CVIFC entry.
let beta = host.create_xrecord(&["CVIFC"], "beta", entries()).unwrap();
let root = host.document().header.named_objects_dict_handle;
let cvifc_entries = match host.document().objects.get(&root) {
Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary
.entries
.iter()
.filter(|(key, _)| key.eq_ignore_ascii_case("CVIFC"))
.count(),
_ => 0,
};
assert_eq!(cvifc_entries, 1);
// Listing names only the keys that address XRecords, in dictionary
// order; a nested dictionary is not an XRecord.
host.create_xrecord(&["CVIFC", "LAYOUTS"], "nested", entries())
.unwrap();
assert_eq!(
host.list_xrecords(&["CVIFC"]).unwrap(),
vec!["alpha".to_owned(), "beta".to_owned()]
);
assert_eq!(
host.list_xrecords(&["CVIFC", "LAYOUTS"]).unwrap(),
vec!["nested".to_owned()]
);
// The root dictionary holds non-XRecord entries (ACAD_LAYOUT et al.);
// listing only reports XRecords.
let root_names = host.list_xrecords(&[]).unwrap();
assert!(!root_names
.iter()
.any(|n| n.eq_ignore_ascii_case("ACAD_LAYOUT")));
// Update refuses on a missing name and never creates anything.
assert!(host
.update_xrecord(&["CVIFC"], "gamma", entries())
.unwrap_err()
.contains("does not exist"));
// Case-insensitive resolution finds the stored ALPHA and replaces its
// entries atomically; the handle is stable.
assert_eq!(
host.update_xrecord(
&["CVIFC"],
"ALPHA",
vec![codec::objects::XRecordEntry::string(1, "v2")]
)
.unwrap(),
alpha
);
assert_eq!(
host.read_xrecord(&["CVIFC"], "alpha").unwrap(),
vec![codec::objects::XRecordEntry::string(1, "v2")]
);
// Entries are validated before any change: reserved and out-of-range
// group codes refuse (5 and 105 are the object handle codes).
assert!(host
.create_xrecord(
&["CVIFC"],
"gamma",
vec![codec::objects::XRecordEntry::int16(5, 1)]
)
.unwrap_err()
.contains("reserved"));
assert!(host
.create_xrecord(
&["CVIFC"],
"gamma",
vec![codec::objects::XRecordEntry::int16(400, 1)]
)
.unwrap_err()
.contains("out of range"));
assert!(host
.update_xrecord(
&["CVIFC"],
"alpha",
vec![codec::objects::XRecordEntry::int16(105, 1)]
)
.is_err());
assert_eq!(
host.read_xrecord(&["CVIFC"], "alpha").unwrap(),
vec![codec::objects::XRecordEntry::string(1, "v2")]
);
// Dictionary and record names are symbol names.
assert!(host.create_xrecord(&["CVIFC"], " ", entries()).is_err());
assert!(host
.create_xrecord(&["CVIFC", "A|B"], "gamma", entries())
.is_err());
assert!(host.create_xrecord(&["CVIFC"], "a<b", entries()).is_err());
// Reads distinguish a missing dictionary, a missing name and a name
// that addresses another object kind.
assert!(host
.read_xrecord(&["NOPE"], "alpha")
.unwrap_err()
.contains("does not exist"));
assert!(host
.read_xrecord(&["CVIFC", "LAYOUTS"], "alpha")
.unwrap_err()
.contains("does not exist"));
assert!(host
.delete_xrecord(&["CVIFC"], "LAYOUTS")
.unwrap_err()
.contains("not an XRecord"));
// Delete removes exactly the named record and returns its content.
let removed = host.delete_xrecord(&["CVIFC"], "beta").unwrap();
assert_eq!(removed.handle, beta);
assert_eq!(removed.entries, entries());
assert_eq!(
host.list_xrecords(&["CVIFC"]).unwrap(),
vec!["alpha".to_owned()]
);
assert!(host
.read_xrecord(&["CVIFC"], "beta")
.unwrap_err()
.contains("does not exist"));
assert!(host.delete_xrecord(&["CVIFC"], "beta").is_err());
}
#[test]
fn xrecord_mutations_are_single_undo_steps_that_restore_objects() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let original_entries = || vec![codec::objects::XRecordEntry::string(1, "one")];
let changed_entries = || {
vec![
codec::objects::XRecordEntry::string(1, "changed"),
codec::objects::XRecordEntry::int16(70, 9),
]
};
let (alpha, beta);
{
let mut host = HostSession::new(&mut app, 0);
alpha = host
.create_xrecord(&["CVIFC"], "alpha", original_entries())
.unwrap();
beta = host
.create_xrecord(&["CVIFC"], "beta", original_entries())
.unwrap();
// Refusals record no undo step.
assert!(host
.create_xrecord(&["CVIFC"], "alpha", original_entries())
.is_err());
assert!(host
.update_xrecord(&["CVIFC"], "gamma", original_entries())
.is_err());
host.update_xrecord(&["CVIFC"], "alpha", changed_entries())
.unwrap();
let removed = host.delete_xrecord(&["CVIFC"], "beta").unwrap();
assert_eq!(removed.handle, beta);
}
app.finish_pending_history(0);
// Four mutating calls = four undo steps; the refusals recorded none.
assert_eq!(app.tabs[0].history.undo_stack.len(), 4);
let xrecord = |doc: &CadDocument, handle: Handle| -> Option<codec::objects::XRecord> {
match doc.objects.get(&handle) {
Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()),
_ => None,
}
};
let has_dictionary_entry = |doc: &CadDocument, name: &str| -> bool {
let root = doc.header.named_objects_dict_handle;
match doc.objects.get(&root) {
Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary
.entries
.iter()
.any(|(key, _)| key.eq_ignore_ascii_case(name)),
_ => false,
}
};
// Undo the delete: beta is back with its exact prior entries.
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert_eq!(xrecord(document, beta).unwrap().entries, original_entries());
// Undo the update: alpha's entries are restored byte-exactly.
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert_eq!(
xrecord(document, alpha).unwrap().entries,
original_entries()
);
// Undo beta's creation.
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert!(xrecord(document, beta).is_none());
assert!(xrecord(document, alpha).is_some());
// Undo alpha's creation: the auto-created dictionary went with it,
// because the pending history snapshot restores the whole objects map.
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert!(xrecord(document, alpha).is_none());
assert!(
!has_dictionary_entry(document, "CVIFC"),
"undoing the first create removed the auto-created dictionary"
);
// Redo re-applies the whole ladder.
app.redo_steps(4);
let document = &app.tabs[0].scene.document;
assert_eq!(xrecord(document, alpha).unwrap().entries, changed_entries());
assert!(xrecord(document, beta).is_none());
}
/// The entity-write composition contract, for XRecords: a plugin opens one
/// `push_undo` scope per operation and performs every write — XRecord and
/// entity, plus refusals — inside it, and the finished scope is a single
/// undo step under the plugin's own label that restores (and re-applies)
/// the whole operation at once.
#[test]
fn xrecord_writes_join_a_plugin_initiated_undo_scope() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let original_entries = || vec![codec::objects::XRecordEntry::string(1, "one")];
let changed_entries = || {
vec![
codec::objects::XRecordEntry::string(1, "changed"),
codec::objects::XRecordEntry::int16(70, 9),
]
};
let (alpha, nested, entity);
{
let mut host = HostSession::new(&mut app, 0);
host.push_undo("CVI: import");
alpha = host
.create_xrecord(&["CVIFC"], "alpha", original_entries())
.unwrap();
nested = host
.create_xrecord(&["CVIFC", "LAYOUTS"], "nested", original_entries())
.unwrap();
// A refusal inside the scope records nothing at all.
assert!(host
.create_xrecord(&["CVIFC"], "ALPHA", original_entries())
.is_err());
assert_eq!(
host.update_xrecord(&["CVIFC"], "alpha", changed_entries())
.unwrap(),
alpha
);
let beta = host
.create_xrecord(&["CVIFC"], "beta", original_entries())
.unwrap();
let removed = host.delete_xrecord(&["CVIFC"], "beta").unwrap();
assert_eq!(removed.handle, beta);
// Entity writes join the same scope and land in the same step.
entity = host.add_entity(EntityType::Point(Point::at(codec::types::Vector3::new(
3.0, 0.0, 0.0,
))));
}
app.finish_pending_history(0);
// The whole plugin operation is ONE undo step under the plugin's label.
assert_eq!(app.tabs[0].history.undo_stack.len(), 1);
assert_eq!(app.tabs[0].history.undo_stack[0].label(), "CVI: import");
let xrecord = |doc: &CadDocument, handle: Handle| -> Option<codec::objects::XRecord> {
match doc.objects.get(&handle) {
Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()),
_ => None,
}
};
let has_dictionary_entry = |doc: &CadDocument, name: &str| -> bool {
let root = doc.header.named_objects_dict_handle;
match doc.objects.get(&root) {
Some(codec::objects::ObjectType::Dictionary(dictionary)) => dictionary
.entries
.iter()
.any(|(key, _)| key.eq_ignore_ascii_case(name)),
_ => false,
}
};
app.undo_steps(1);
let document = &app.tabs[0].scene.document;
assert!(document.get_entity(entity).is_none());
assert!(xrecord(document, alpha).is_none());
assert!(xrecord(document, nested).is_none());
assert!(
!has_dictionary_entry(document, "CVIFC"),
"undoing the operation removed its auto-created dictionary namespace"
);
app.redo_steps(1);
let document = &app.tabs[0].scene.document;
assert!(matches!(
document.get_entity(entity),
Some(EntityType::Point(_))
));
let alpha_record = xrecord(document, alpha).unwrap();
assert_eq!(alpha_record.entries, changed_entries());
assert!(xrecord(document, nested).is_some());
assert!(
has_dictionary_entry(document, "CVIFC"),
"redoing the operation re-seeded its namespace"
);
}
#[test]
fn xrecords_survive_dxf_and_dwg_round_trips() {
let mut app = OpenCADStudio::new_for_test();
app.tabs[0].is_start = false;
let mut host = HostSession::new(&mut app, 0);
let entries = || {
vec![
codec::objects::XRecordEntry::string(1, "{\"k\":\"road\",\"s\":1}"),
codec::objects::XRecordEntry::double(40, 0.125),
codec::objects::XRecordEntry::int16(70, 3),
codec::objects::XRecordEntry::int32(90, -45000),
codec::objects::XRecordEntry::bool(290, true),
]
};
// Create carries a first payload, then the update replaces it: if the
// writers preferred stale raw bytes over the typed entries, the loaded
// record would still read "stale".
host.create_xrecord(
&["CVIFC", "RECORDS"],
"alpha",
vec![codec::objects::XRecordEntry::string(1, "stale")],
)
.unwrap();
host.update_xrecord(&["CVIFC", "RECORDS"], "alpha", entries())
.unwrap();
let find_xrecord =
|doc: &CadDocument, path: &[&str], name: &str| -> Option<codec::objects::XRecord> {
let mut current = doc.header.named_objects_dict_handle;
for fragment in path {
match doc.objects.get(&current) {
Some(codec::objects::ObjectType::Dictionary(dictionary)) => {
current = dictionary.get(fragment)?
}
_ => return None,
}
}
let Some(codec::objects::ObjectType::Dictionary(dictionary)) =
doc.objects.get(&current)
else {
return None;
};
let handle = dictionary.get(name)?;
match doc.objects.get(&handle) {
Some(codec::objects::ObjectType::XRecord(record)) => Some(record.clone()),
_ => None,
}
};
let dwg = crate::io::load_bytes(
"xrec.dwg",
codec::DwgWriter::write_to_vec(host.document()).unwrap(),
)
.unwrap();
let reloaded = find_xrecord(&dwg, &["CVIFC", "RECORDS"], "alpha")
.expect("XRecord in the reloaded DWG");
assert_eq!(reloaded.entries, entries());
let dxf = crate::io::load_bytes(
"xrec.dxf",
codec::DxfWriter::new(host.document())
.write_to_vec()
.unwrap(),
)
.unwrap();
let reloaded = find_xrecord(&dxf, &["CVIFC", "RECORDS"], "alpha")
.expect("XRecord in the reloaded DXF");
assert_eq!(reloaded.entries, entries());
}
/// Opt-in end-to-end check using the staged Python cdylib and the actual
/// OCS executable as its out-of-process runner. Run with OCS_TEST_PYTHON_PLUGIN
/// and OCS_PLUGIN_RUNNER_EXE set; unlike the local-socket protocol test,
+2
View File
@@ -39,6 +39,8 @@ fn extrusion_history(
sweep_entity_transform: glam::DMat4::IDENTITY.to_cols_array(),
path_entity_transform: glam::DMat4::IDENTITY.to_cols_array(),
reference_point: Vector3::new(anchor.x, anchor.y, anchor.z),
// Group 295: the profile frame is given (see extrusion_history).
flags_294_296: [false, true, false],
..SolidHistorySweep::default()
}));
}
+38 -1
View File
@@ -1158,7 +1158,7 @@ fn apply_updates(
fn json_values_match(actual: &Value, expected: &Value) -> bool {
match (actual, expected) {
(Value::Number(a), Value::Number(b)) if a.is_f64() || b.is_f64() => {
a.as_f64() == b.as_f64()
numbers_match(a, b)
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(a, b)| json_values_match(a, b))
@@ -1172,6 +1172,28 @@ fn json_values_match(actual: &Value, expected: &Value) -> bool {
}
}
/// `10.0` matches `10`, but an integer past f64 precision never matches a
/// float it merely rounds onto: a mixed pair is equal only when the float is
/// whole and converts back to exactly that integer.
fn numbers_match(a: &serde_json::Number, b: &serde_json::Number) -> bool {
let (int, float) = match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) if a.is_f64() && b.is_f64() => return x == y,
(_, Some(y)) if b.is_f64() => (a, y),
(Some(x), _) => (b, x),
_ => return false,
};
if !float.is_finite() || float.fract() != 0.0 {
return false;
}
if let Some(i) = int.as_i64() {
i as f64 == float && float as i64 == i
} else if let Some(u) = int.as_u64() {
u as f64 == float && float as u64 == u
} else {
false
}
}
fn patched_table_entry<T>(
table: &Table<T>,
request: &Value,
@@ -1645,6 +1667,21 @@ mod tests {
use super::OpenCADStudio;
/// Whole floats match their integer; integers past f64 precision never
/// match the neighbouring float they round onto.
#[test]
fn numbers_match_by_value_without_precision_loss() {
let number = |value: Value| match value {
Value::Number(number) => number,
_ => unreachable!(),
};
assert!(super::numbers_match(&number(json!(10)), &number(json!(10.0))));
assert!(!super::numbers_match(&number(json!(10)), &number(json!(10.5))));
let past_precision = number(json!(9_007_199_254_740_993_u64));
assert!(!super::numbers_match(&past_precision, &number(json!(9_007_199_254_740_992.0))));
assert!(super::numbers_match(&number(json!(-3)), &number(json!(-3.0))));
}
fn execute(app: &mut OpenCADStudio, mut request: Value, id: &str) -> Value {
let state = app.automation_op(r#"{"protocol":1,"op":"state"}"#);
let object = request.as_object_mut().unwrap();
+6
View File
@@ -419,6 +419,10 @@ pub struct UserSettings {
pub field_display: bool,
/// SURFACEASSOCIATIVITY: new surfaces stay associative to their sources.
pub surface_associativity: bool,
/// UCSORTHO: an orthographic view preset also sets its orthographic UCS.
pub ucs_ortho: bool,
/// UCSVIEW: a saved named view keeps the current UCS.
pub ucs_view: bool,
/// Nested-copy symbol handling: false inserts, true binds.
pub ncopy_bind: bool,
/// Last Options page; unknown saved names fall back without rejecting the config.
@@ -836,6 +840,8 @@ impl Default for UserSettings {
attdef_on_screen: true,
field_display: true,
surface_associativity: true,
ucs_ortho: true,
ucs_view: true,
ncopy_bind: false,
cursor_type: CursorType::Crosshair,
crosshair_color: None,
+23
View File
@@ -4,6 +4,28 @@ use super::{Message, OpenCADStudio};
use iced::Task;
use rustc_hash::FxHashMap;
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
/// True when the live `SPACE` binding resolves to something other than
/// `COMMANDSPACE`. The view's key-dispatch closure must stay a non-capturing fn
/// pointer, so instead of reading the binding map it reads this flag when a
/// text field reports a Captured space. When SPACE is default-bound to
/// COMMANDSPACE the flag is false and the text field keeps the space for
/// typing; when SPACE is rebound (e.g. to FINALIZE) the flag is true so the
/// Captured space still fires its shortcut. (#1710)
pub(crate) static SPACE_REBOUND_OFF_COMMANDSPACE: AtomicBool = AtomicBool::new(false);
/// Recompute the SPACE-dispatch flag from the current binding map. Call after
/// every change to `shortcut_bindings`. (#1710)
pub(super) fn sync_space_dispatch_flag(bindings: &FxHashMap<String, String>) {
SPACE_REBOUND_OFF_COMMANDSPACE
.store(space_rebound_off_commandspace(bindings), Ordering::Relaxed);
}
/// Whether `SPACE` is bound to something other than `COMMANDSPACE`. (#1710)
pub(crate) fn space_rebound_off_commandspace(bindings: &FxHashMap<String, String>) -> bool {
matches!(bindings.get("SPACE"), Some(cmd) if cmd != "COMMANDSPACE")
}
#[cfg(target_os = "macos")]
const ACCEL: &str = "CMD";
@@ -263,6 +285,7 @@ impl OpenCADStudio {
})
.collect();
self.shortcut_bindings = bindings;
sync_space_dispatch_flag(&self.shortcut_bindings);
self.persist_settings_if_changed();
}
+70 -1
View File
@@ -8,7 +8,13 @@ impl OpenCADStudio {
self.pending_startup_modals
.push_back(ModalKind::AssocPrompt);
}
if self.donation_prompt_version != env!("OCS_APP_VERSION") {
// Automation sessions (#1349): once control traffic has arrived (or a
// web host opted in at boot) while control is enabled, the donation
// prompt is suppressed — a modal would block every automated
// operation until a client closed it by hand.
if self.donation_prompt_version != env!("OCS_APP_VERSION")
&& !(self.control.enabled && self.control.automation_seen)
{
self.pending_startup_modals
.push_back(ModalKind::DonationPrompt);
}
@@ -17,6 +23,15 @@ impl OpenCADStudio {
pub(super) fn show_next_startup_modal(&mut self) {
if self.active_modal.is_none() && self.opening.is_none() && self.pending_opens.is_empty() {
// A queued AutosaveRestore can outlive its pending list when a
// concurrent open displaces the modal and the entry is consumed
// meanwhile; resurfacing it would show an empty prompt.
#[cfg(target_arch = "wasm32")]
while self.pending_startup_modals.front() == Some(&ModalKind::AutosaveRestore)
&& self.web_autosave_pending.is_empty()
{
self.pending_startup_modals.pop_front();
}
if let Some(&kind) = self.pending_startup_modals.front() {
self.active_modal = Some(kind);
self.reset_modal_geometry();
@@ -24,6 +39,27 @@ impl OpenCADStudio {
}
}
/// Queue the web crash-recovery prompt behind whatever startup modal is
/// already showing (donation, GPU warning…); the after-message hook in
/// `update` surfaces it as soon as the slot is free. No-op when nothing
/// is pending: once the autosave OPFS directory merely exists (even with
/// no entries), queueing unconditionally would show an empty
/// "Recover Drawing" modal on every boot.
#[cfg(target_arch = "wasm32")]
pub(super) fn queue_autosave_restore_prompt(&mut self) {
if self.web_autosave_pending.is_empty() {
return;
}
if !self
.pending_startup_modals
.contains(&ModalKind::AutosaveRestore)
{
self.pending_startup_modals
.push_back(ModalKind::AutosaveRestore);
}
self.show_next_startup_modal();
}
/// Notice when the scene is drawn by a software rasterizer, or not at
/// all, and say so everywhere the user might look: the command line,
/// the log, a one-time popup, and a status-bar pill that stays.
@@ -196,6 +232,39 @@ mod tests {
assert!(restarted.active_modal.is_none());
}
#[test]
fn automation_sessions_skip_the_donation_prompt() {
// First control contact drops a still-queued donation prompt and
// stops later queueing while control stays enabled (#1349).
let mut app = OpenCADStudio::new_for_test();
app.default_assoc_prompted = true;
app.queue_startup_prompts();
assert_eq!(app.active_modal, Some(ModalKind::DonationPrompt));
let _ = app.update(Message::CloseModal);
app.donation_prompt_version = "previous-release".to_string();
app.pending_startup_modals.push_back(ModalKind::DonationPrompt);
let _ = app.control_request(serde_json::json!({"op":"state"}));
assert!(app.pending_startup_modals.is_empty());
app.queue_startup_prompts();
assert_ne!(app.active_modal, Some(ModalKind::DonationPrompt));
// A still-queued (not yet shown) prompt is dropped on first contact.
let mut queued = OpenCADStudio::new_for_test();
queued.default_assoc_prompted = true;
queued.donation_prompt_version = "previous-release".to_string();
queued.pending_startup_modals.push_back(ModalKind::DonationPrompt);
let _ = queued.control_request(serde_json::json!({"op":"state"}));
queued.queue_startup_prompts();
assert_ne!(queued.active_modal, Some(ModalKind::DonationPrompt));
// A prompt already on screen is left for its reader; clients close
// it themselves, and a session with no control traffic still prompts.
let mut human = OpenCADStudio::new_for_test();
human.default_assoc_prompted = true;
human.queue_startup_prompts();
assert_eq!(human.active_modal, Some(ModalKind::DonationPrompt));
}
#[test]
fn startup_dialogs_wait_their_turn_and_preserve_unseen_prompts() {
let mut app = OpenCADStudio::new_for_test();
File diff suppressed because it is too large. Load diff
+64 -15
View File
@@ -51,6 +51,44 @@ impl OpenCADStudio {
self.persist_settings_if_changed();
}
BlockPaletteMsg::Refresh => self.refresh_block_palette(),
// Web: read the picked file's bytes and parse them right away —
// the picker's name-only path has nothing behind it to read later.
#[cfg(target_arch = "wasm32")]
BlockPaletteMsg::PickFile => {
return Task::perform(
async {
crate::io::pick_and_parse_web(
crate::t!("Select Drawing to Insert as Block").as_ref(),
crate::t!("DWG/DXF Files").as_ref(),
&["dwg", "dxf", "DWG", "DXF"],
)
.await
.and_then(|picked| picked.ok_or_else(|| "Cancelled".to_string()))
},
|r| Message::BlockPalette(BlockPaletteMsg::FilePickedDoc(r)),
);
}
#[cfg(target_arch = "wasm32")]
BlockPaletteMsg::FilePickedDoc(Ok((name, document))) => {
let stem = std::path::Path::new(&name)
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.unwrap_or_else(|| "Block".to_string());
match self.import_document_block(document, stem, false) {
Ok(name) => {
self.refresh_block_palette();
return self.start_palette_insert(&name, None);
}
Err(e) => self.command_line.push_error(&e),
}
}
#[cfg(target_arch = "wasm32")]
BlockPaletteMsg::FilePickedDoc(Err(e)) => {
if e != "Cancelled" {
self.command_line.push_error(&e);
}
}
#[cfg(not(target_arch = "wasm32"))]
BlockPaletteMsg::PickFile => {
return Task::perform(
async {
@@ -134,7 +172,7 @@ impl OpenCADStudio {
/// BLOCKSPALETTE: open (expanded) on `tab`, or on the tab it was left on.
pub(crate) fn open_blocks_palette(&mut self, tab: Option<Tab>) {
self.show_block_palette = true;
self.dock_expanded = Some(crate::ui::dock::PanelId::BlockPalette);
self.dock_reveal(crate::ui::dock::PanelId::BlockPalette);
self.show_block_palette_tab(tab.unwrap_or(self.block_palette.tab));
}
@@ -491,20 +529,31 @@ impl OpenCADStudio {
Tab::Current => {
let name = item.name();
let i = self.active_tab;
let referenced = self.tabs[i].scene.document.entities().any(|e| {
matches!(e, codec::EntityType::Insert(ins) if ins.block_name.eq_ignore_ascii_case(&name))
});
if referenced {
self.command_line.push_error(&crate::tf!(
"Block \"{name}\" is referenced in the drawing and cannot be removed."
));
} else {
self.push_undo_snapshot(i, "PURGE");
crate::app::control::erase_block_definition(&mut self.tabs[i].scene, &name);
self.tabs[i].dirty = true;
self.command_line
.push_output(&crate::tf!("Block \"{name}\" removed."));
self.refresh_block_palette();
let open_edits: Vec<codec::Handle> = self.tabs[i]
.block_edits
.iter()
.map(|session| session.br_handle)
.collect();
self.push_undo_snapshot(i, format!("Delete block {name}"));
let outcome = crate::scene::block_service::delete(
&mut self.tabs[i].scene,
&name,
crate::scene::block_service::DeleteOptions {
force: false,
open_edits: &open_edits,
},
);
match outcome {
Ok(_) => {
self.tabs[i].dirty = true;
self.command_line
.push_output(&crate::tf!("Block \"{name}\" removed."));
self.refresh_block_palette();
}
Err(error) => {
self.discard_last_undo_entry(i);
self.command_line.push_error(&error.to_string());
}
}
}
Tab::Recent | Tab::Favorites => {
+132 -17
View File
@@ -195,6 +195,13 @@ impl OpenCADStudio {
// This tab is closing for good — drop its autosave recovery copy.
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(self.autosave_target(idx));
// …and, on the web, its retained in-place save handle: the next tab
// to reuse that id must start with a fresh pick instead of silently
// writing over the closed drawing's file.
#[cfg(target_arch = "wasm32")]
if let Some(closing_id) = closing_tab_id {
crate::io::fs_access::drop_handle(closing_id);
}
// Only-tab case: when the lone non-start tab closes, fall
// back to the Start tab if it exists; otherwise spawn a
// fresh blank drawing (legacy behaviour).
@@ -219,6 +226,10 @@ impl OpenCADStudio {
if let Some(tab_id) = tab_id {
v4_support::on_tab_closed(tab_id);
}
// The tab list just shrank: refresh the remembered session list
// (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
Task::none()
}
@@ -394,6 +405,10 @@ impl OpenCADStudio {
// dropdown so the dispatched command's new prompt is
// immediately visible on the overlay.
self.command_line.close_history();
if self.awaiting_fill_close_answer() {
let input = std::mem::take(&mut self.command_line.input);
return self.on_graphic_fill_close_input(&input);
}
// A leading `>` was only a "literal spaces" typing hint (see
// CommandSpace) — drop it before the input is interpreted.
// Free-form text prompts keep it: there it is content, not a
@@ -814,27 +829,17 @@ impl OpenCADStudio {
let is_mtp = raw.trim_start_matches('_').eq_ignore_ascii_case("MTP")
|| raw.trim_start_matches('_').eq_ignore_ascii_case("M2P");
if is_mtp {
let is_point_step = !self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.input_kind().wants_text())
.unwrap_or(true)
|| self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.point_step_accepts_keywords())
.unwrap_or(false);
let not_entity_pick = !self.tabs[i]
.active_cmd
.as_ref()
.map(|c| c.needs_entity_pick())
.unwrap_or(false);
if is_point_step && not_entity_pick {
if self.point_modifier_step(i) {
self.command_line.input.clear();
self.start_mtp_modifier(i);
return self.focus_cmd_input();
}
}
if let Some(kind) = self.typed_snap_override(i, &raw) {
self.command_line.input.clear();
self.apply_snap_override(kind);
return self.focus_cmd_input();
}
let text = if free_text {
raw
} else {
@@ -933,6 +938,7 @@ impl OpenCADStudio {
self.dyn_coord_absolute = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.snapper.clear_override();
self.push_ucs_to_cmd(i);
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(wcs_pt));
if let Some(r) = result {
@@ -1048,6 +1054,7 @@ impl OpenCADStudio {
self.dyn_coord_absolute = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.snapper.clear_override();
if let Some((_, dir)) = distance_ray {
self.snapper.remember_distance_reference(dir);
}
@@ -1108,6 +1115,9 @@ impl OpenCADStudio {
if !self.command_line.input.trim().is_empty() {
return self.update(Message::CommandSubmit);
}
if self.awaiting_fill_close_answer() {
return self.on_graphic_fill_close_input("");
}
// A grip edit is not an active CAD command, but its Dynamic Input
// fields use the same keyboard path. Route Enter through CommandSubmit,
// whose grip branch resolves Distance / Angle and finalizes the edit.
@@ -1162,6 +1172,11 @@ impl OpenCADStudio {
if self.ribbon.escape_extension() {
return Task::none();
}
if self.awaiting_fill_close_answer() {
self.graphic_attributes.pending_fill_close = None;
self.command_line.push_info(crate::t!("*Cancel*").as_ref());
return Task::none();
}
// Esc drops an unconsumed one-shot snap override and closes
// its menu (#337). Falls through — Esc keeps its usual effect.
self.snap_override_popup = None;
@@ -2087,6 +2102,68 @@ impl OpenCADStudio {
}
}
/// Attach a clipboard image read on the web (a pasted screenshot) the way
/// the IMAGE command's pick does: register the bytes name-keyed, decode
/// for the file's true pixel size, then commit the entity — but placed
/// directly at the cursor (0,0 before any pointer move) with the command's
/// default width, skipping the pick prompts a paste should not run.
#[cfg(target_arch = "wasm32")]
pub(super) fn paste_clipboard_image(&mut self, name: String, bytes: Vec<u8>) -> Task<Message> {
let i = self.active_tab;
if self.tabs[i].is_start {
self.command_line.push_error(
crate::tr!("common", "no-drawing-open-use-new-or-open-first").as_ref(),
);
return Task::none();
}
crate::scene::model::image_model::register_source(&name, std::sync::Arc::new(bytes));
let Some(decoded) = crate::scene::model::image_model::resolve_image(&name) else {
self.command_line.push_error(
crate::tr!("clipboard", "image-unsupported", name = name.clone()).as_ref(),
);
return Task::none();
};
let (pixels_w, pixels_h) = (decoded.source_width, decoded.source_height);
// Same default footprint as the IMAGE command's Enter placement: one
// world unit per 100 px (min 1), height from the true aspect ratio.
let width = (pixels_w as f64 / 100.0).max(1.0);
let aspect = if pixels_h == 0 {
1.0
} else {
pixels_w as f64 / pixels_h as f64
};
let height = width / aspect;
let origin = self.tabs[i].last_cursor_world;
let mut image = codec::entities::RasterImage::with_size(
&name,
codec::types::Vector3::new(origin.x, origin.y, origin.z),
pixels_w as f64,
pixels_h as f64,
width,
height,
);
image.flags = codec::entities::ImageDisplayFlags::SHOW_IMAGE
| codec::entities::ImageDisplayFlags::USE_CLIPPING_BOUNDARY;
let short = std::path::Path::new(&name)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&name)
.to_string();
self.command_line.push_output(
crate::tr!(
"clipboard",
"pasted-image",
name = short,
width = pixels_w,
height = pixels_h
)
.as_ref(),
);
self.apply_cmd_result(crate::command::CmdResult::CommitAndExit(
codec::EntityType::RasterImage(image),
))
}
pub(super) fn on_qselect_open(&mut self) -> Task<Message> {
let i = self.active_tab;
self.tabs[i].scene.selection.borrow_mut().menu.open_at = None;
@@ -2233,6 +2310,35 @@ impl OpenCADStudio {
Task::none()
}
pub(super) fn on_ribbon_lineweight_changed(
&mut self,
lw: codec::types::LineWeight,
) -> Task<Message> {
let i = self.active_tab;
self.ribbon.close_dropdown();
let handles = self.property_target_handles(i);
if handles.is_empty() {
if self.has_property_selection(i) {
return Task::none();
}
// Persist into the tab's header (CELWEIGHT). #21.
self.tabs[i].scene.document.header.current_line_weight = lw.value();
self.tabs[i].dirty = true;
self.ribbon.active_lineweight = lw;
} else {
// Lineweight is baked into the cached wire geometry —
// re-tessellate so the change shows immediately (issue #231
// class).
self.apply_property_op(i, "CHPROP", &handles, |app, handle| {
if let Some(entity) = app.tabs[i].scene.document.get_entity_mut(handle) {
crate::scene::view::dispatch::apply_line_weight(entity, lw);
}
});
self.ribbon.active_lineweight = lw;
}
Task::none()
}
pub(super) fn on_ribbon_linetype_changed(&mut self, lt: String) -> Task<Message> {
let i = self.active_tab;
self.ribbon.close_dropdown();
@@ -2339,11 +2445,20 @@ impl OpenCADStudio {
pub(super) fn on_prop_hatch_pattern_changed(&mut self, name: String) -> Task<Message> {
let i = self.active_tab;
let handles = self.property_target_handles(i);
self.apply_hatch_pattern(i, &handles, name)
}
pub(super) fn apply_hatch_pattern(
&mut self,
i: usize,
handles: &[codec::Handle],
name: String,
) -> Task<Message> {
if !handles.is_empty() {
use crate::scene::model::hatch_patterns;
if let Some(entry) = hatch_patterns::find(&name) {
self.push_undo_snapshot(i, "HATCHEDIT");
for &handle in &handles {
for &handle in handles {
if let Some(codec::EntityType::Hatch(dxf)) =
self.tabs[i].scene.document.get_entity_mut(handle)
{
+54 -318
View File
@@ -13,6 +13,7 @@ use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
use crate::ui::window::block_palette::BlockPaletteMsg;
use crate::ui::PropertiesPanel;
use codec::types::Color as AcadColor;
use codec::{EntityType as AcadEntityType, Handle};
@@ -21,6 +22,15 @@ use iced::{mouse, Point, Task};
impl OpenCADStudio {
pub(in crate::app) fn open_save_dialog_window(&mut self, tab_idx: usize) -> Task<Message> {
// Every entry here is Save-As semantics (the SAVEAS command, the
// recovery save-as, the native failure redirects): the user is
// choosing a destination, so on the web the FS Access picker must
// open even when the tab already holds a handle (plain Save sets
// the flag back in save_with_default_format).
#[cfg(target_arch = "wasm32")]
{
self.save_plain_reuse_fs_handle = false;
}
// Default the format dropdown to the loaded file's own format — its
// DWG-vs-DXF kind (from the extension) and its version (from the parsed
// document) — so Save-As round-trips the format instead of silently
@@ -188,9 +198,33 @@ impl OpenCADStudio {
let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else {
return Task::batch([close_win, self.continue_tab_close_queue()]);
};
// …and, on the web, its retained in-place save handle — the
// same hook on_tab_close runs: the user declined the save,
// so the picked file must never receive a later write, and
// the next tab to reuse this id must start with a fresh
// pick instead of silently writing over the discarded
// drawing's file.
#[cfg(target_arch = "wasm32")]
crate::io::fs_access::drop_handle(tab_id);
// Discarded — drop this tab's autosave recovery copy.
#[cfg(not(target_arch = "wasm32"))]
let _ = std::fs::remove_file(self.autosave_target(idx));
// Web counterpart: the OPFS snapshot must go too, or the
// drawing the user just declined to save is offered back at
// the next boot. A snapshot round still writing this tab's key
// would resurrect it after the remove, so defer to the round's
// end (the same guard the save flow uses).
#[cfg(target_arch = "wasm32")]
{
let key = Self::web_autosave_key(&self.tabs[idx]);
if self.web_autosave_in_flight {
self.web_autosave_clear_after.push(key);
} else {
wasm_bindgen_futures::spawn_local(async move {
let _ = crate::io::web_autosave::remove(&key).await;
});
}
}
if self.tabs.len() == 1 {
self.tab_counter += 1;
self.tabs[0] = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
@@ -208,6 +242,10 @@ impl OpenCADStudio {
// last selection. #21.
self.sync_ribbon_layers();
self.sync_ribbon_from_selection();
// The tab list just shrank: refresh the remembered
// session list (web session memory).
#[cfg(target_arch = "wasm32")]
self.persist_web_session();
return Task::batch([close_win, self.continue_tab_close_queue()]);
}
Some(crate::app::PendingClose::Quit) => {
@@ -289,6 +327,21 @@ impl OpenCADStudio {
#[cfg(target_arch = "wasm32")]
{
// Same guard the native half runs against active_save_jobs
// above: while a web save is between capture and completion,
// refuse a second one (the window is seconds — refuse, don't
// queue) instead of letting two serializer rounds race for the
// same tab's bookkeeping.
if let Some(busy_tab) = crate::io::fs_access::web_save_in_flight() {
if self.tabs.iter().any(|tab| tab.id == busy_tab) {
self.command_line
.push_info(crate::t!("Save already running for this drawing.").as_ref());
return Task::none();
}
// Stale marker (its tab closed before settling): clear it
// rather than block this save forever.
crate::io::fs_access::end_web_save(busy_tab);
}
match self.pending_close.take() {
Some(crate::app::PendingClose::Tab(tab_id)) => {
let Some(idx) = self.tabs.iter().position(|t| t.id == tab_id) else {
@@ -379,7 +432,7 @@ impl OpenCADStudio {
self.import_document_block(doc, stem, false)
}
fn import_document_block(
pub(super) fn import_document_block(
&mut self,
doc: codec::CadDocument,
stem: String,
@@ -542,195 +595,6 @@ impl OpenCADStudio {
Ok(name)
}
/// Dock chrome interaction (grab / pin / resize / hover / move) applied to
/// whichever panel the message names.
pub(super) fn on_dock(&mut self, m: crate::ui::dock::DockMsg) -> iced::Task<Message> {
use crate::app::config::DockSide;
use crate::ui::dock::{DockMsg, PanelId};
match m {
DockMsg::DockGrab(id) => {
self.dock_dragging = Some(id);
self.dock_resizing = None;
self.dock_drag_last = None;
self.dock_drag_target = self.dock.location(id);
self.dock_expanded = Some(id);
iced::Task::none()
}
DockMsg::ResizeGrab(id) => {
self.dock_resizing = Some(id);
self.dock_dragging = None;
self.dock_drag_last = None;
self.dock_drag_target = None;
self.dock_expanded = Some(id);
iced::Task::none()
}
DockMsg::WidthReset(id) => {
self.dock.reset_width(id);
self.save_config();
iced::Task::none()
}
DockMsg::AutoCollapseToggle(id) => {
let on = !self.dock.auto_collapse(id);
self.dock.set_auto_collapse(id, on);
self.dock_expanded = if on { None } else { Some(id) };
self.save_config();
iced::Task::none()
}
DockMsg::Close(id) => {
match id {
PanelId::BlockPalette => {
self.show_block_palette = false;
self.block_palette.placing = None;
}
PanelId::ExternalReferences => {
self.show_external_references = false;
}
PanelId::Browser => {
self.show_browser = false;
}
PanelId::NodeGraph => {
self.show_node_graph = false;
}
PanelId::PointCloudManager => {
self.pc_manager.show = false;
}
PanelId::Count => {
self.count_palette.show = false;
self.ribbon.set_count_palette(false);
}
PanelId::SheetSetManager => {
self.sheet_set.show = false;
self.ribbon.set_sheet_set(false);
}
PanelId::Properties => {
self.show_properties = false;
self.ribbon.set_properties(false);
}
}
if self.dock_expanded == Some(id) {
self.dock_expanded = None;
}
if self.dock_dragging == Some(id) {
self.dock_dragging = None;
}
if self.dock_resizing == Some(id) {
self.dock_resizing = None;
}
iced::Task::none()
}
DockMsg::Hover(id) => {
// Ignored while dragging/resizing: the pointer is over the
// drag preview, not a rail, so a hover must not collapse the
// panel being dragged or repoint the resize target.
if self.dock_dragging.is_none() && self.dock_resizing.is_none() {
self.dock_expanded = Some(id);
}
iced::Task::none()
}
DockMsg::HoverExit => {
if self.dock_dragging.is_none() && self.dock_resizing.is_none() {
if let Some(id) = self.dock_expanded {
if self.dock.auto_collapse(id) {
self.dock_expanded = None;
}
}
}
iced::Task::none()
}
DockMsg::DragMove(point) => {
// NOTE: xref column drags intentionally do NOT ride this
// path: its points live in workspace space while the header
// tracker reports header-local points, and mixing the two
// produced a one-time jump plus a stuck drag. Column moves
// arrive via Message::XrefColMove only.
if self.dock_dragging.is_some() {
let avail = self.tabs[self.active_tab]
.scene
.selection
.borrow()
.view
.vp_size
.1;
let side = if point.x < self.win_size.0 * 0.5 {
DockSide::Left
} else {
DockSide::Right
};
let index = crate::ui::dock::drop_index(
point.y,
std::cmp::max(self.dock_visible_len(side), 1),
avail,
);
self.dock_drag_target = Some((side, index));
} else if let Some(id) = self.dock_resizing {
if let Some(last) = self.dock_drag_last {
let dx = point.x - last.x;
let delta = match self.dock.location(id) {
Some((DockSide::Left, _)) => dx,
_ => -dx,
};
let cur = self.dock.settings(id).width + delta;
self.dock.set_width(id, cur);
}
}
if self.dock_dragging.is_some() || self.dock_resizing.is_some() {
self.dock_drag_last = Some(point);
}
iced::Task::none()
}
DockMsg::DragRelease => {
if let Some(id) = self.dock_dragging {
if let Some((side, index)) = self.dock_drag_target {
if self.dock.dock(id, side, index) {
self.save_config();
}
}
}
self.dock_dragging = None;
self.dock_resizing = None;
self.dock_drag_last = None;
self.dock_drag_target = None;
self.xref_col_drag = None;
self.xref_col_last = None;
self.xref_split_drag = false;
iced::Task::none()
}
}
}
/// Whether `id` is currently rendered (not closed, not on the start screen /
/// clean-screen viewport). Mirrors the visibility filter the edge column
/// renderer applies before building each side's stack.
pub(crate) fn dock_panel_visible(&self, id: crate::ui::dock::PanelId) -> bool {
use crate::ui::dock::PanelId;
if self.tabs[self.active_tab].is_start || self.clean_screen {
return false;
}
match id {
PanelId::Properties => self.show_properties,
PanelId::BlockPalette => self.show_block_palette,
PanelId::ExternalReferences => self.show_external_references,
PanelId::Browser => self.show_browser,
PanelId::NodeGraph => self.show_node_graph,
PanelId::PointCloudManager => self.pc_manager.show,
PanelId::Count => self.count_palette.show,
PanelId::SheetSetManager => self.sheet_set.show,
}
}
/// Number of panels currently rendered on `side`. Closed panels keep their
/// stack slot but take no vertical space, so drag preview geometry must
/// size slots against only the panels that are actually visible.
pub(crate) fn dock_visible_len(&self, side: crate::app::config::DockSide) -> usize {
let ids: &[crate::ui::dock::PanelId] = match side {
crate::app::config::DockSide::Left => &self.dock.left,
crate::app::config::DockSide::Right => &self.dock.right,
};
ids.iter()
.filter(|id| self.dock_panel_visible(**id))
.count()
}
/// Rebuild the Reference Manager's entry list from the active drawing.
/// Uses the tab's session sets (unloaded + stat baseline) so CLI and
/// palette agree; writes fresh load-time mtimes back into the tab's stat
@@ -1605,134 +1469,6 @@ mod tests {
);
}
#[test]
fn blockpalette_pin_toggles_autocollapse_and_close_hides() {
let mut app = fresh();
app.show_block_palette = true;
let id = crate::ui::dock::PanelId::BlockPalette;
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
assert!(app.dock.auto_collapse(id), "pin enables auto-collapse");
let _ = app.on_dock(crate::ui::dock::DockMsg::AutoCollapseToggle(id));
assert!(
!app.dock.auto_collapse(id),
"second pin disables auto-collapse"
);
let _ = app.on_dock(crate::ui::dock::DockMsg::Close(id));
assert!(!app.show_block_palette, "close dismisses the sidebar");
}
#[test]
fn blockpalette_dock_moves_to_other_side_and_persists() {
let mut app = fresh();
// Tests load the user's persisted config; reset the dock to a known
// state so this stays hermetic.
app.dock = Default::default();
let id = crate::ui::dock::PanelId::BlockPalette;
assert_eq!(
app.dock.location(id),
Some((crate::app::config::DockSide::Right, 0))
);
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
app.win_size = (1600.0, 900.0).into();
let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(
100.0, 100.0,
)));
assert_eq!(
app.dock_drag_target,
Some((crate::app::config::DockSide::Left, 0))
);
let _ = app.on_dock(crate::ui::dock::DockMsg::DragRelease);
assert_eq!(
app.dock.location(id),
Some((crate::app::config::DockSide::Left, 0))
);
}
#[test]
fn dock_visible_len_counts_only_rendered_panels() {
let mut app = fresh();
app.dock = Default::default();
app.dock.left = vec![
crate::ui::dock::PanelId::BlockPalette,
crate::ui::dock::PanelId::Properties,
];
// Left: block palette hidden, properties shown -> 1 visible panel.
app.show_block_palette = false;
app.show_properties = true;
assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1);
// Reveal the block palette -> both count.
app.show_block_palette = true;
assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 2);
// A hidden (closed) panel counts for nothing even when stacked.
app.show_block_palette = false;
assert_eq!(app.dock_visible_len(crate::app::config::DockSide::Left), 1);
}
#[test]
fn dock_drag_target_ignores_hidden_panels_on_the_side() {
// Reproduce the persisted layout that exposed a half-height ghost: two
// panels live in the left stack but the block palette is hidden
// (show_block_palette=false), so only Properties renders. Drag geometry
// must count only panels that are actually visible.
let mut app = fresh();
app.dock = Default::default();
app.dock.left = vec![
crate::ui::dock::PanelId::BlockPalette,
crate::ui::dock::PanelId::Properties,
];
app.show_block_palette = false;
app.show_properties = true;
let i = app.active_tab;
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
app.win_size = (1600.0, 900.0).into();
let id = crate::ui::dock::PanelId::Properties;
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
// Pointer near the bottom of the left edge: one visible panel means a
// single slot, so every y maps to index 0 (no top/bottom split).
let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(
100.0, 850.0,
)));
assert_eq!(
app.dock_drag_target,
Some((crate::app::config::DockSide::Left, 0))
);
}
#[test]
fn dock_drag_target_counts_all_visible_panels() {
let mut app = fresh();
app.dock = Default::default();
app.dock.left = vec![
crate::ui::dock::PanelId::BlockPalette,
crate::ui::dock::PanelId::Properties,
];
// Both panels shown: two real slots on the left edge. A pointer near
// the bottom maps to the append slot (index == 2).
app.show_block_palette = true;
app.show_properties = true;
let i = app.active_tab;
app.tabs[i].scene.selection.borrow_mut().view.vp_size = (1600.0, 900.0);
app.win_size = (1600.0, 900.0).into();
let id = crate::ui::dock::PanelId::Properties;
let _ = app.on_dock(crate::ui::dock::DockMsg::DockGrab(id));
let _ = app.on_dock(crate::ui::dock::DockMsg::DragMove(iced::Point::new(
100.0, 850.0,
)));
assert_eq!(
app.dock_drag_target,
Some((crate::app::config::DockSide::Left, 2))
);
}
#[test]
fn blockpalette_width_reset() {
let mut app = fresh();
let id = crate::ui::dock::PanelId::BlockPalette;
app.dock.set_width(id, 500.0);
let _ = app.on_dock(crate::ui::dock::DockMsg::WidthReset(id));
assert_eq!(app.dock.settings(id).width, 260.0);
}
#[test]
fn block_name_from_file_avoids_collisions() {
let mut app = fresh();
+2
View File
@@ -682,6 +682,7 @@ impl OpenCADStudio {
self.dyn_coord_absolute = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.snapper.clear_override();
self.snapper.remember_distance_reference(dir);
self.push_ucs_to_cmd(i);
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
@@ -755,6 +756,7 @@ impl OpenCADStudio {
self.dyn_coord_absolute = false;
self.sync_dyn_fields();
self.reset_tracking_after_point();
self.snapper.clear_override();
self.push_ucs_to_cmd(i);
let result = self.tabs[i].active_cmd.as_mut().map(|c| c.on_point(pt));
for f in self.tabs[i].dyn_fields.iter_mut() {
+1306 -121
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+762 -141
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -13,6 +13,7 @@ use crate::scene::pick::grip::{find_hit_grip, find_hit_grip_paper, find_hit_grip
use crate::scene::{
self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX,
};
use crate::ui::window::graphic_attributes::GraphicAttributesMsg;
use crate::ui::PropertiesPanel;
use codec::types::Color as AcadColor;
use codec::{EntityType as AcadEntityType, Handle};
@@ -1180,6 +1181,17 @@ impl OpenCADStudio {
}
let s = crate::ui::color_select::color_to_aci_string(color);
let edit = match self.color_picker.take().map(|picker| picker.target) {
Some(crate::app::ColorPickTarget::Gradient(index)) => {
Some(Message::GraphicAttributes(GraphicAttributesMsg::Gradient(
crate::ui::window::gradient_editor::GradientMsg::Color(index, color),
)))
}
Some(crate::app::ColorPickTarget::GraphicAttributesLine) => Some(
Message::GraphicAttributes(GraphicAttributesMsg::LineColor(color)),
),
Some(crate::app::ColorPickTarget::GraphicAttributesSolid) => Some(
Message::GraphicAttributes(GraphicAttributesMsg::FillColor(color)),
),
Some(crate::app::ColorPickTarget::DimStyle(f)) => Some(Message::DsEdit(f, s)),
Some(crate::app::ColorPickTarget::MLeader(f)) => {
Some(Message::MLeaderStyleEdit { field: f, value: s })
+1 -1
View File
@@ -6,7 +6,7 @@ use crate::scene::Scene;
/// Returns (1.0, 1.0) for "Fit" or unknown formats.
/// Sync the model-space annotation scale into its named drawing variable and
/// numeric header mirror before a save.
pub(super) fn sync_annotation_scale_header(scene: &mut Scene) {
pub(in crate::app) fn sync_annotation_scale_header(scene: &mut Scene) {
let anno = scene.annotation_scale;
let value = if anno.abs() > 1e-9 {
1.0 / anno as f64
+17 -5
View File
@@ -5976,15 +5976,25 @@ properties={:.1}ms picked={}",
}
fn snap_view_region(&mut self, region: CubeRegion, r_ucs: glam::Mat4) -> Task<Message> {
self.snap_view_region_as(region, r_ucs, true)
}
/// A standard view preset (-VIEW): the world view itself, kept when it
/// is shown already, with no cube message.
pub(in crate::app) fn snap_view_preset(&mut self, region: CubeRegion) -> Task<Message> {
self.snap_view_region_as(region, glam::Mat4::IDENTITY, false)
}
fn snap_view_region_as(&mut self, region: CubeRegion, r_ucs: glam::Mat4, cube: bool) -> Task<Message> {
let i = self.active_tab;
self.clear_navigation_hover(i);
self.tabs[i].scene.remember_current_view();
let mut region = region;
// "Already there → flip to opposite" check: compare the
// current gaze direction with the region's target gaze.
// "Already there → flip to opposite" check for a cube click: compare
// the current gaze direction with the region's target gaze.
let target_dir = r_ucs.transform_vector3(region.snap_direction());
let cur_dir = self.tabs[i].scene.active_gaze_dir();
if cur_dir.dot(target_dir) > 0.9999 {
if cube && cur_dir.dot(target_dir) > 0.9999 {
region = region.opposite();
}
let eye_dir = r_ucs.transform_vector3(region.snap_direction());
@@ -6014,8 +6024,10 @@ properties={:.1}ms picked={}",
}
}
self.tabs[i].scene.camera_generation += 1;
self.command_line
.push_output(crate::tf!("View: {}", crate::t!(region.label())).as_ref());
if cube {
self.command_line
.push_output(crate::tf!("View: {}", crate::t!(region.label())).as_ref());
}
Task::none()
}
+53
View File
@@ -50,6 +50,59 @@ impl OpenCADStudio {
self.accepted_snaps.clear();
}
/// The active command is at a point prompt (keywords allowed) and not
/// picking an object: where MTP and typed snap overrides apply.
pub(crate) fn point_modifier_step(&self, i: usize) -> bool {
self.tabs[i].active_cmd.as_ref().is_some_and(|c| {
(!c.input_kind().wants_text() || c.point_step_accepts_keywords())
&& !c.needs_entity_pick()
})
}
/// A snap override typed at a point prompt (`MID`, `END`, `NON`, ...). An
/// option the prompt offers wins, so `CEN` where Center is offered picks
/// Center.
pub(crate) fn typed_snap_override(
&self,
i: usize,
token: &str,
) -> Option<crate::snap::SnapOverrideKind> {
let kind = crate::snap::parse_snap_override(token)?;
if !self.point_modifier_step(i) {
return None;
}
let typed = token.trim().trim_start_matches('_').to_uppercase();
let offered = self.tabs[i].active_cmd.as_ref()?.options().iter().any(|option| {
option.keyword.eq_ignore_ascii_case(&typed)
|| option.label.to_uppercase().replace(' ', "").starts_with(&typed)
});
(!offered).then_some(kind)
}
/// Apply a transparent one-shot snap override (from typed input or the context menu).
pub(crate) fn apply_snap_override(&mut self, kind: crate::snap::SnapOverrideKind) {
match kind {
crate::snap::SnapOverrideKind::Mode(t) => {
self.snap_override_popup = None;
self.snapper.set_override(t);
let label = crate::snap::ALL_SNAP_MODES
.iter()
.chain(crate::snap::ALL_3D_SNAP_MODES.iter())
.find(|(m, _, _)| *m == t)
.map(|(_, _, l)| *l)
.unwrap_or("Snap");
self.command_line
.push_info(crate::tf!("Snap override: {label} (next pick only).").as_ref());
}
crate::snap::SnapOverrideKind::None => {
self.snap_override_popup = None;
self.snapper.set_override_none();
self.command_line
.push_info(crate::t!("Snap override: None (next pick only).").as_ref());
}
}
}
/// Record the snap a point step just accepted. `snap` is the displayed snap
/// result (already in the current space's coordinates) or `None` for a free
/// / typed point; `committed` is the point the command actually received
+38 -440
View File
@@ -9,11 +9,11 @@ use crate::t;
use crate::ui::wrap_bar::DensitySwap;
use crate::ui::wrap_bar::WrapFlow;
use iced::widget::{
button, canvas, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader,
stack, text, Row, Space,
button, column, container, mouse_area, pane_grid, responsive, row, scrollable, shader, stack,
text, Row, Space,
};
use iced::window;
use iced::{keyboard, Background, Border, Color, Element, Fill, Length, Subscription, Task, Theme};
use iced::{keyboard, Background, Border, Color, Element, Fill, Subscription, Task, Theme};
use iced_aw::ContextMenu;
use std::sync::Arc;
@@ -1889,29 +1889,10 @@ bg={bg_ms:.1}ms n={view_count}"
viewport_stack = viewport_stack.push(tab.graph.view(sections));
}
// Docked side panels (Properties, block palette, future palettes) live
// in an ordered vertical stack on the left/right edge of the drawing
// view. Auto-collapsing (pinned) panels that aren't being hovered
// reduce to a rail sharing 1/N of the edge column's height; the
// hovered or unpinned panel expands to the full column height on top.
// Single source of truth with dock_visible_len (#57): one new panel
// updated in only one place used to break drag math vs rendering.
let visible_panel = |id: crate::ui::dock::PanelId| -> bool { self.dock_panel_visible(id) };
let edge_stack = |side: crate::app::config::DockSide| -> Option<Element<'_, Message>> {
let ids: Vec<crate::ui::dock::PanelId> = match side {
crate::app::config::DockSide::Left => self.dock.left.clone(),
crate::app::config::DockSide::Right => self.dock.right.clone(),
}
.into_iter()
.filter(|id| visible_panel(*id))
.collect();
if ids.is_empty() {
return None;
}
Some(self.build_edge_stack(side, &ids, tab))
};
let left_edge = edge_stack(crate::app::config::DockSide::Left);
let right_edge = edge_stack(crate::app::config::DockSide::Right);
// Docked pallets: each edge's icon strip and shown group (see
// `app::dock`).
let left_edge = self.build_edge_stack(crate::app::config::DockSide::Left, tab);
let right_edge = self.build_edge_stack(crate::app::config::DockSide::Right, tab);
// Command-line sits as a bottom-centre overlay on top of the
// viewport stack rather than as a separate row in the main
@@ -1957,158 +1938,7 @@ bg={bg_ms:.1}ms n={view_count}"
}
let workspace: Element<'_, Message> = row(parts).width(Fill).height(Fill).into();
let any_dragging = self.dock_dragging.is_some();
let any_resizing =
self.dock_resizing.is_some() || self.xref_col_drag.is_some() || self.xref_split_drag;
let workspace = if any_dragging {
let id = self.dock_dragging.expect("guarded by any_dragging");
let side = self.dock_drag_target.map(|(s, _)| s).unwrap_or(
self.dock
.location(id)
.map(|(s, _)| s)
.unwrap_or(crate::app::config::DockSide::Right),
);
// Dropping onto an edge joins that edge's stack, whose column is as
// wide as its widest currently-shown panel (the dragged panel
// included). Hidden (closed) panels don't render, so they can't
// widen the column being previewed.
let preview_width = {
let mut widths: Vec<f32> = match side {
crate::app::config::DockSide::Left => &self.dock.left,
crate::app::config::DockSide::Right => &self.dock.right,
}
.iter()
.copied()
.filter(|&pid| self.dock_panel_visible(pid))
.map(|pid| self.dock.width(pid, self.win_size.0))
.collect();
widths.push(self.dock.width(id, self.win_size.0));
widths.into_iter().fold(0.0, f32::max)
};
// Faint tint over the whole edge column so the target edge reads
// even before the panel-sized ghost snaps to a slot.
let edge_tint = container(Space::new())
.width(Length::Fixed(preview_width))
.height(Fill)
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(
palette.primary.weak.color.scale_alpha(0.35),
)),
..Default::default()
}
});
// The ghost is the dragged panel at its real size: its own saved
// width and its 1/N share of the edge (N = number of panels shown
// on the edge after the drop), with a header naming which panel is
// being moved. Only visibly-docked panels share the column height:
// a hidden (closed) panel keeps its stack slot but no screen space,
// so it must not split the preview.
let was_here = self.dock.location(id).map(|(s, _)| s) == Some(side);
let visible = self.dock_visible_len(side);
let final_count = if was_here {
std::cmp::max(visible, 1)
} else {
visible + 1
};
let edge_h = tab.scene.selection.borrow().view.vp_size.1;
let slot_h = edge_h / final_count as f32;
let index = self.dock_drag_target.map(|(_, i)| i).unwrap_or(0);
let slot = index.min(final_count.saturating_sub(1));
let ghost_top = slot as f32 * slot_h;
let ghost_header = container(text(id.title()).size(12))
.width(Fill)
.padding(iced::Padding {
top: 5.0,
right: 8.0,
bottom: 5.0,
left: 8.0,
})
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.primary.base.color)),
text_color: Some(palette.primary.base.text),
..Default::default()
}
});
let ghost_panel = container(column![ghost_header, Space::new()])
.width(Length::Fixed(self.dock.width(id, self.win_size.0)))
.height(Length::Fixed(slot_h))
.style(|theme: &Theme| {
let palette = theme.palette();
container::Style {
background: Some(Background::Color(palette.background.base.color)),
border: Border {
color: palette.primary.base.color,
width: 2.0,
radius: 0.0.into(),
},
..Default::default()
}
});
// Position the ghost at its drop slot, flush against the edge.
let ghost = container(ghost_panel)
.width(Fill)
.height(Fill)
.align_x(match side {
crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left,
crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right,
})
.align_y(iced::alignment::Vertical::Top)
.padding(iced::Padding {
top: ghost_top,
right: 0.0,
bottom: 0.0,
left: 0.0,
});
// Thin line across the column at the drop slot's top boundary.
let drop_line = container(
container(Space::new())
.width(Fill)
.height(Length::Fixed(3.0))
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().primary.base.color)),
..Default::default()
}),
)
.width(Fill)
.height(Fill)
.align_y(iced::alignment::Vertical::Top)
.padding(iced::Padding {
top: ghost_top,
right: 0.0,
bottom: 0.0,
left: 0.0,
});
let preview = iced::widget::stack![edge_tint, ghost, drop_line]
.width(Length::Fixed(preview_width))
.height(Fill);
let preview = container(preview)
.width(Fill)
.height(Fill)
.align_x(match side {
crate::app::config::DockSide::Left => iced::alignment::Horizontal::Left,
crate::app::config::DockSide::Right => iced::alignment::Horizontal::Right,
});
stack![workspace, preview].width(Fill).height(Fill).into()
} else {
workspace
};
let workspace: Element<'_, Message> = if any_dragging || any_resizing {
mouse_area(workspace)
.on_move(move |p| Message::Dock(crate::ui::dock::DockMsg::DragMove(p)))
.on_release(Message::Dock(crate::ui::dock::DockMsg::DragRelease))
.interaction(if any_resizing {
iced::mouse::Interaction::ResizingHorizontally
} else {
iced::mouse::Interaction::Grabbing
})
.into()
} else {
workspace
};
let workspace = self.dock_overlay(workspace, tab);
let command_line = self.command_line.view(
allow_autocomplete,
dyn_capturing,
@@ -2581,6 +2411,18 @@ impl OpenCADStudio {
} else {
Subscription::none()
};
// C11: park a `capture` request until the window draws another frame;
// a timer fallback prevents a request from hanging when no redraw is
// pending.
let capture_frame = if self.control.capture_frame_pending {
Subscription::batch([
window::frames().map(|_| Message::ControlCaptureFrame { redrew: true }),
iced::time::every(std::time::Duration::from_millis(50))
.map(|_| Message::ControlCaptureFrame { redrew: false }),
])
} else {
Subscription::none()
};
// Blink the MText preview caret while the editor is open.
let caret_blink = if self.mtext_editor.is_some() {
iced::time::every(std::time::Duration::from_millis(530))
@@ -2596,9 +2438,9 @@ impl OpenCADStudio {
iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts);
#[cfg(not(target_arch = "wasm32"))]
let web_fonts = Subscription::none();
// Periodic autosave to a `.sv$` recovery file (native only). SAVETIME is
// the interval in minutes; 0 disables it.
#[cfg(not(target_arch = "wasm32"))]
// Periodic autosave (SAVETIME): a `.sv$` recovery copy beside the file
// on native, an OPFS snapshot on web. SAVETIME is the interval in
// minutes; 0 disables it.
let autosave = if self.savetime_min > 0 {
iced::time::every(std::time::Duration::from_secs(
self.savetime_min as u64 * 60,
@@ -2607,8 +2449,6 @@ impl OpenCADStudio {
} else {
Subscription::none()
};
#[cfg(target_arch = "wasm32")]
let autosave = Subscription::none();
// Drawings handed over by a second launch (single instance). Inert in a
// process that lost the port election, so it costs nothing there.
#[cfg(not(target_arch = "wasm32"))]
@@ -2791,9 +2631,20 @@ impl OpenCADStudio {
}
}
let shortcut = shortcut_key_name(&key, modifiers)?;
(status == Status::Ignored
|| crate::app::shortcuts::is_global_key(&shortcut))
.then_some(Message::ShortcutPressed(shortcut))
if status == Status::Ignored
|| crate::app::shortcuts::is_global_key(&shortcut)
{
return Some(Message::ShortcutPressed(shortcut));
}
// A text field captured the key. SPACE rebound away from
// COMMANDSPACE still fires from the command line, but
// only there: other fields keep typing spaces. This
// closure is a plain fn, so the binding is read from a
// flag and the focus is asked for in update. (#1710)
(shortcut == "SPACE"
&& crate::app::shortcuts::SPACE_REBOUND_OFF_COMMANDSPACE
.load(std::sync::atomic::Ordering::Relaxed))
.then_some(Message::CommandLineSpaceProbe)
}
_ => None,
}
@@ -2836,6 +2687,7 @@ impl OpenCADStudio {
nav_settle,
gpu_probe,
thumbnail_capture,
capture_frame,
caret_blink,
web_fonts,
autosave,
@@ -2854,198 +2706,6 @@ impl OpenCADStudio {
pub(super) fn unfocus_widgets(&self) -> Task<Message> {
iced::advanced::widget::operate(iced::advanced::widget::operation::focusable::unfocus())
}
/// Build one edge: a narrow tab strip at the very edge plus the expanded
/// panels sliding out beside it. Auto-collapsing (pinned) panels are always
/// visible as equal-height 1/N tabs in the strip (via `Fill` distribution);
/// hovering a tab raises its panel to full column height beside the strip,
/// so switching between stacked panels is a direct tab-to-tab hover with no
/// width-jumping collapse dance. Unpinned panels are always expanded. The
/// whole edge is one hover region: leaving it collapses any auto-collapsing
/// panel, but moving between the tabs and the expanded panel does not.
fn build_edge_stack<'a>(
&'a self,
side: crate::app::config::DockSide,
ids: &[crate::ui::dock::PanelId],
tab: &'a DocumentTab,
) -> Element<'a, Message> {
let pinned: Vec<crate::ui::dock::PanelId> = ids
.iter()
.copied()
.filter(|id| self.dock.auto_collapse(*id))
.collect();
// Expanded = unpinned panels (always open) in stack order, then the
// hovered auto-collapsing one last so it floats on top.
let mut expanded: Vec<crate::ui::dock::PanelId> = ids
.iter()
.copied()
.filter(|id| !self.dock.auto_collapse(*id))
.collect();
if let Some(id) = self.dock_expanded {
if ids.contains(&id) && self.dock.auto_collapse(id) {
expanded.push(id);
}
}
// Each expanded panel renders at its own saved width; the column is as
// wide as the widest one currently showing.
let col_w = expanded
.iter()
.map(|id| self.dock.width(*id, self.win_size.0))
.fold(0.0, f32::max);
let tab_strip = (!pinned.is_empty()).then(|| {
let tabs: Vec<Element<'_, Message>> = pinned
.iter()
.map(|id| {
self.rail_slice(
*id,
side,
self.dock_expanded == Some(*id),
self.dock_dragging == Some(*id),
)
})
.collect();
column(tabs).width(DOCK_RAIL_W).height(Fill).into()
});
let expanded_stack = (!expanded.is_empty()).then(|| {
// Multiple simultaneously-expanded panels (e.g. two unpinned panels
// on the same edge) stack one below the other, each taking 1/N of
// the column height via equal `Fill` shares — never overlapping.
let layers: Vec<Element<'_, Message>> = expanded
.iter()
.map(|id| self.expanded_panel(*id, side, col_w, tab))
.collect();
column(layers).height(Fill).into()
});
let mut children: Vec<Element<'_, Message>> = Vec::new();
match side {
crate::app::config::DockSide::Left => {
if let Some(t) = tab_strip {
children.push(t);
}
if let Some(e) = expanded_stack {
children.push(e);
}
}
crate::app::config::DockSide::Right => {
if let Some(e) = expanded_stack {
children.push(e);
}
if let Some(t) = tab_strip {
children.push(t);
}
}
}
mouse_area(row(children).height(Fill))
.on_exit(Message::Dock(crate::ui::dock::DockMsg::HoverExit))
.into()
}
/// One narrow tab in the edge strip. Equal `Fill` heights across the tabs
/// give each 1/N of the strip; hovering or clicking it raises its panel.
fn rail_slice(
&self,
id: crate::ui::dock::PanelId,
side: crate::app::config::DockSide,
is_active: bool,
is_dragging: bool,
) -> Element<'_, Message> {
let label = canvas(VBarLabel {
text: id.title().to_string(),
clockwise: side == crate::app::config::DockSide::Left,
})
.width(Fill)
.height(Fill);
let bg = move |theme: &Theme| {
let palette = theme.palette();
if is_active {
palette.primary.weak.color
} else if is_dragging {
palette.primary.weak.color.scale_alpha(0.55)
} else {
palette.background.base.color
}
};
let tab = container(label)
.width(Length::Fixed(DOCK_RAIL_W))
.height(Fill)
.style(move |theme: &Theme| container::Style {
background: Some(Background::Color(bg(theme))),
border: Border {
color: if is_active {
theme.palette().primary.base.color
} else {
theme.palette().background.neutral.color
},
width: 1.0,
radius: 0.0.into(),
},
..Default::default()
});
mouse_area(tab)
.interaction(iced::mouse::Interaction::Pointer)
.on_press(Message::Dock(crate::ui::dock::DockMsg::DockGrab(id)))
.on_enter(Message::Dock(crate::ui::dock::DockMsg::Hover(id)))
.into()
}
/// A panel expanded to the full edge column: the panel body plus a
/// grabbable divider against the viewport. Hovering is handled by the
/// enclosing edge region (see `build_edge_stack`), so the body stays fully
/// interactive without fighting the region's hover tracking.
fn expanded_panel<'a>(
&'a self,
id: crate::ui::dock::PanelId,
side: crate::app::config::DockSide,
width: f32,
tab: &'a DocumentTab,
) -> Element<'a, Message> {
let auto_collapse = self.dock.auto_collapse(id);
let panel: Element<'_, Message> = match id {
crate::ui::dock::PanelId::Properties => tab.properties.view(width, auto_collapse),
crate::ui::dock::PanelId::BlockPalette => {
crate::ui::window::block_palette::view(&self.block_palette, width, auto_collapse)
}
crate::ui::dock::PanelId::ExternalReferences => {
self.xref_manager
.view(width, auto_collapse, tab.xref_missing, &tab.scene.document)
}
crate::ui::dock::PanelId::Browser => crate::ui::window::browser::view(
&tab.scene.document,
tab.sketch_session
.as_ref()
.map(|session| session.name.as_str()),
width,
auto_collapse,
),
crate::ui::dock::PanelId::NodeGraph => tab.graph.panel(width, auto_collapse),
crate::ui::dock::PanelId::Count => crate::ui::window::count_palette::view(
&self.count_palette,
tab.count.as_ref(),
&tab.scene.document,
tab.id,
tab.scene.geometry_epoch,
width,
auto_collapse,
),
crate::ui::dock::PanelId::PointCloudManager => crate::ui::window::pc_manager::view(
&self.pc_manager,
&tab.scene.document,
&tab.scene.selected_handles_in_order(),
width,
auto_collapse,
),
crate::ui::dock::PanelId::SheetSetManager => {
crate::ui::window::sheet_set::view(&self.sheet_set, width, auto_collapse)
}
};
let divider = dock_divider(id);
match side {
crate::app::config::DockSide::Left => row![panel, divider].height(Fill).into(),
crate::app::config::DockSide::Right => row![divider, panel].height(Fill).into(),
}
}
}
// ── Document tab bar ───────────────────────────────────────────────────────
@@ -3374,68 +3034,6 @@ fn pane_mouse_area<'a>(idx: usize) -> Element<'a, Message> {
.into()
}
/// Canvas that draws a label rotated 90° (for a collapsed panel's bar).
struct VBarLabel {
text: String,
clockwise: bool,
}
impl canvas::Program<Message> for VBarLabel {
type State = ();
fn draw(
&self,
_state: &(),
renderer: &iced::Renderer,
theme: &Theme,
bounds: iced::Rectangle,
_cursor: iced::advanced::mouse::Cursor,
) -> Vec<canvas::Geometry> {
let mut frame = canvas::Frame::new(renderer, bounds.size());
frame.with_save(|frame| {
frame.translate(iced::Vector::new(bounds.width / 2.0, bounds.height / 2.0));
frame.rotate(iced::Radians(if self.clockwise {
std::f32::consts::FRAC_PI_2
} else {
-std::f32::consts::FRAC_PI_2
}));
frame.fill_text(canvas::Text {
content: self.text.clone(),
position: iced::Point::ORIGIN,
color: theme.palette().background.base.text.scale_alpha(0.72),
size: iced::Pixels(13.0),
align_x: iced::advanced::text::Alignment::Center,
align_y: iced::alignment::Vertical::Center,
shaping: iced::advanced::text::Shaping::Advanced,
..Default::default()
});
});
vec![frame.into_geometry()]
}
}
/// Width of a collapsed (auto-collapsing) panel's tab in the edge strip.
const DOCK_RAIL_W: f32 = 28.0;
/// Grabbable separator for a docked panel managed by the general dock. Same
/// visual as the previous per-panel divider but emits generic dock messages.
fn dock_divider(id: crate::ui::dock::PanelId) -> Element<'static, Message> {
let line = container(Space::new())
.width(Length::Fixed(5.0))
.height(Fill)
.style(|theme: &Theme| container::Style {
background: Some(Background::Color(theme.palette().background.neutral.color)),
..Default::default()
});
let grab = Message::Dock(crate::ui::dock::DockMsg::ResizeGrab(id));
let reset = Message::Dock(crate::ui::dock::DockMsg::WidthReset(id));
mouse_area(line)
.on_press(grab)
.on_double_click(reset)
.interaction(iced::mouse::Interaction::ResizingHorizontally)
.into()
}
const START_ACTION_RADIUS: f32 = 6.0;
fn start_action_shape(mut style: button::Style) -> button::Style {
+86 -7
View File
@@ -19,7 +19,6 @@ impl OpenCADStudio {
Some(K::PluginManager) => crate::tr!("modal", "plugin-manager"),
Some(K::UpdateNotice) => crate::tr!("modal", "update-available"),
Some(K::DonationPrompt) => crate::tr!("donation", "title"),
Some(K::Layers) => crate::tr!("modal", "layer-manager"),
Some(K::LayerStateManager) => crate::tr!("modal", "layer-state-manager"),
Some(K::LayerTranslator) => crate::t!("Layer Translator").into_owned(),
Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(),
@@ -78,6 +77,8 @@ impl OpenCADStudio {
Some(K::Recovery) => crate::tr!("modal", "recovery-report"),
Some(K::MissingFonts) => crate::t!("Missing fonts").into_owned(),
Some(K::RecoveryPrompt) => crate::tr!("modal", "recovery-prompt"),
#[cfg(target_arch = "wasm32")]
Some(K::AutosaveRestore) => crate::t!("Recover Drawing").into_owned(),
Some(K::GpuWarning) => crate::tr!("gpu", "title"),
Some(K::XrefHelp) => crate::t!("Reference Manager Help").into_owned(),
Some(K::SheetSet) => self
@@ -464,12 +465,6 @@ impl OpenCADStudio {
crate::ui::window::update_notice::view_window(latest, body, flow)
})
}
super::super::ModalKind::Layers => {
let tab = &self.tabs[self.active_tab];
sized_flow(ex, 900, 360, |flow| {
tab.layers.view_window(self.layer_name_col_w, flow)
})
}
super::super::ModalKind::LayerTranslator => {
use crate::modules::draw::layers::laytrans;
let i = self.active_tab;
@@ -1814,6 +1809,24 @@ impl OpenCADStudio {
crate::ui::window::recovery::view_prompt(&opening.name, error, flow)
})
}
#[cfg(target_arch = "wasm32")]
super::super::ModalKind::AutosaveRestore => {
let name = self
.web_autosave_pending
.first()
.map(|entry| entry.name.clone())
.unwrap_or_default();
let pending = self.web_autosave_pending.len();
// What the crashed session had open, when it remembered.
let session_drawings = self
.web_session
.as_ref()
.map(|session| session.tabs.len())
.filter(|count| *count > 0);
automatic_flow(ex, |flow| {
autosave_restore_dialog_window(&name, pending, session_drawings, flow)
})
}
})
}
}
@@ -2061,6 +2074,72 @@ fn unsaved_changes_dialog_window(
.into()
}
/// Web crash-recovery prompt: an autosave snapshot of `name` is newer than
/// the drawing's last save. Restore opens it as a recovered tab; Discard
/// deletes the snapshot. With more than one snapshot still pending, "Restore
/// all" puts the whole remaining list through the same guarded open queue.
/// When the crashed session left a tab list behind, a muted line says how
/// much it had open. Mirrors the unsaved-changes dialog's layout.
#[cfg(target_arch = "wasm32")]
fn autosave_restore_dialog_window(
name: &str,
pending: usize,
session_drawings: Option<usize>,
sizing: crate::ui::modal::ModalSizing,
) -> Element<'static, Message> {
let mut body = column![text(crate::tf!(
"\"{name}\" has an autosave copy newer than the last save."
))
.size(14)]
.spacing(0);
if let Some(count) = session_drawings {
body = body
.push(iced::widget::Space::new().height(4))
.push(
text(crate::tf!("Session had {count} open drawing(s).", count = count))
.size(12)
.style(dialog_muted_text_style),
);
}
body = body
.push(iced::widget::Space::new().height(4))
.push(
text(t!("Restore it as a recovered tab, or discard the copy."))
.size(12)
.style(dialog_muted_text_style),
)
.push(iced::widget::Space::new().height(20));
let mut buttons = row![dialog_button(
t!("Restore"),
Message::WebAutosaveRestore,
button::primary
)];
// Only worth its width once there is a batch to take in one click.
if pending > 1 {
buttons = buttons
.push(iced::widget::Space::new().width(8))
.push(dialog_button(
t!("Restore all"),
Message::WebAutosaveRestoreAll,
button::secondary,
));
}
buttons = buttons
.push(iced::widget::Space::new().width(8))
.push(dialog_button(
t!("Discard"),
Message::WebAutosaveDiscard,
button::danger,
));
body = body.push(buttons);
container(body)
.style(dialog_body_style)
.center_x(sizing.width)
.center_y(sizing.height)
.padding([24, 28])
.into()
}
#[cfg(not(target_arch = "wasm32"))]
fn file_in_use_dialog_window(
path: &str,
+18 -9
View File
@@ -1125,6 +1125,8 @@ const MENU_CURSOR_INSET_X: f32 = 24.0;
/// not the row carries a glyph (object snaps, command icons).
const MENU_GUTTER_W: f32 = 18.0;
const MENU_ICON_SIZE: f32 = 14.0;
/// Trailing check mark of checked rows; sits in the submenu caret column.
const MENU_CHECK_SIZE: f32 = 10.0;
/// The gutter cell: the row's glyph, faded with a disabled row, or empty
/// space of the same width.
@@ -1264,16 +1266,13 @@ pub(super) fn viewport_context_menu_overlay(
position_canvas_overlay_clamped(pos, bottom_inset, offset, panel.into())
}
/// One menu row: label (bold for the default row, "✓ "-prefixed when
/// checked), and the keyword hint right-aligned and dimmed. Disabled rows
/// render without a press handler and with faded text.
/// One menu row: label (bold for the default row), the keyword hint
/// right-aligned and dimmed, and a check mark in the trailing column (where
/// submenu headers draw their caret) when checked, so checked labels stay
/// aligned with the others. Disabled rows render without a press handler and
/// with faded text and icons.
fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<'static, Message> {
let label = if item.checked {
format!("✓ {}", item.label)
} else {
item.label.clone()
};
let mut label_text = text(label).size(12);
let mut label_text = text(item.label.clone()).size(12);
if item.default {
label_text = label_text.font(iced::Font {
weight: iced::font::Weight::Bold,
@@ -1299,6 +1298,16 @@ fn context_menu_row(item: &MenuItem, indent: f32, highlighted: bool) -> Element<
}),
);
}
if item.checked {
if item.hint.is_none() {
content = content.push(iced::widget::Space::new().width(Fill));
}
content = content.push(if enabled {
crate::ui::icons::themed(crate::ui::icons::CHECK, MENU_CHECK_SIZE)
} else {
crate::ui::icons::themed_disabled(crate::ui::icons::CHECK, MENU_CHECK_SIZE)
});
}
let mut btn = button(content)
.padding(iced::Padding {
top: 3.0,
+4 -1
View File
@@ -136,7 +136,10 @@ impl OpenCADStudio {
return;
};
let wp = ins.get_transform().apply(Vector3::new(
// `def_point` is a block-definition coordinate (see the codec's
// BlockVisibilityParameter), so the grip follows the same base-point
// convention as the rendered reference.
let wp = crate::scene::render_graph::insert_transform(doc, ins).apply(Vector3::new(
param.def_point.x,
param.def_point.y,
param.def_point.z,