forked from 1b-agent/opencadstudio-upstream
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:
220 files changed
+37313
-5826
No files matched your search
+481
-69
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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(¤t_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(¤t_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
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
+394
-21
@@ -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,
|
||||
|
||||
@@ -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
@@ -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(¤t) {
|
||||
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(¤t) {
|
||||
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(¤t) {
|
||||
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(¤t)
|
||||
{
|
||||
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(¤t) {
|
||||
Some(codec::objects::ObjectType::Dictionary(dictionary)) => {
|
||||
current = dictionary.get(fragment)?
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let Some(codec::objects::ObjectType::Dictionary(dictionary)) =
|
||||
doc.objects.get(¤t)
|
||||
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,
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+762
-141
File diff suppressed because it is too large.
Load diff
@@ -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 })
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user