From 10d1d9b7d9f4b051cc104b4a38db933245ff53d5 Mon Sep 17 00:00:00 2001 From: Marcel Date: Wed, 9 Sep 2026 20:49:29 +0200 Subject: [PATCH 01/53] Implement DELOBJ source deletion policies --- src/app/command_driver.rs | 58 ++++++++++++++++++++++++++++------ src/app/commands/draw.rs | 52 +++++++++++++++++++++++++++++- src/app/commands/styleprops.rs | 41 +++++++++++++++++++----- src/app/mod.rs | 49 ++++++++++++++++++++++++++++ src/app/model_ops.rs | 4 ++- src/app/settings.rs | 9 ++++++ src/app/update/file.rs | 2 ++ 7 files changed, 196 insertions(+), 19 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 00268c3bc..f151d3738 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1779,7 +1779,7 @@ impl OpenCADStudio { } => { let label = self.history_label_from_active_cmd(i, "SOLID"); let erase_source = erase_source.filter(|handle| { - self.tabs[i].scene.document.header.delete_objects + self.delete_objects != 0 && !self.tabs[i].scene.is_layer_locked(*handle) }); let pending = self.begin_undo( @@ -3661,7 +3661,7 @@ impl OpenCADStudio { taper_angle, color: _, } => { - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_sources = self.delete_objects != 0; if handles.is_empty() || handles .iter() @@ -3948,7 +3948,7 @@ impl OpenCADStudio { self.restore_pre_cmd_tangent(); return Task::none(); } - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_sources = self.delete_objects != 0; use crate::command::ExtrudeMode; use crate::modules::insert::solid3d_cmds::{ empty_revolved_surface, empty_solid3d, @@ -4082,7 +4082,9 @@ impl OpenCADStudio { use crate::command::ExtrudeMode; use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::model::sweep_model; - let delete_sources = self.tabs[i].scene.document.header.delete_objects; + let delete_objects = self.delete_objects; + let delete_profiles = + crate::app::delobj_deletes_profiles(delete_objects); let path = self.tabs[i].scene.document.get_entity(path_handle).cloned(); let mut profiles = Vec::new(); let mut failed = 0usize; @@ -4120,6 +4122,14 @@ impl OpenCADStudio { failed += 1; continue; }; + let deletes_path = crate::app::delobj_deletes_auxiliary( + delete_objects, + surface, + ); + if deletes_path && self.tabs[i].scene.is_layer_locked(path_handle) { + failed += 1; + continue; + } let created = if surface { self.add_surface_model(sweep_model::swept_surface_entity(&record), body) } else { @@ -4132,9 +4142,12 @@ impl OpenCADStudio { failed += 1; } else { created_handles.push(created); - if delete_sources { + if delete_profiles { consumed.push(handle); } + if deletes_path { + consumed.push(path_handle); + } } } if !created_handles.is_empty() { @@ -4175,12 +4188,15 @@ impl OpenCADStudio { }).collect::>(); sources.sort_unstable_by_key(|handle| handle.value()); sources.dedup(); - let delete_sources = self.tabs[i].scene.document.header.delete_objects; - let locked = delete_sources && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); + let delete_objects = self.delete_objects; + let delete_sections = + crate::app::delobj_deletes_profiles(delete_objects); + let section_locked = delete_sections + && sources.iter().any(|handle| self.tabs[i].scene.is_layer_locked(*handle)); let available = sources.iter().chain(guides.iter()).copied().chain(path) .filter_map(|handle| self.tabs[i].scene.document.get_entity(handle) .cloned().map(|entity| (handle, entity))).collect::>(); - let result = if locked { + let result = if section_locked { Err("LOFT: a source is on a locked layer; disable source deletion or unlock it.".to_string()) } else { loft_command_model::record(§ions, &guides, path, &available, mode, options) @@ -4189,6 +4205,19 @@ impl OpenCADStudio { match result { Ok((body, record)) => { let surface = record.parameters.as_ref().is_some_and(|settings| settings.surface); + let delete_auxiliary = crate::app::delobj_deletes_auxiliary( + delete_objects, + surface, + ); + if delete_auxiliary + && guides.iter().copied().chain(path) + .any(|handle| self.tabs[i].scene.is_layer_locked(handle)) + { + self.command_line.push_error( + "LOFT: a source is on a locked layer; disable source deletion or unlock it.", + ); + return Task::none(); + } let dirty_before = self.tabs[i].dirty; let pending = self.begin_undo(i, "LOFT", 1, true); let created = if surface { @@ -4210,7 +4239,18 @@ impl OpenCADStudio { self.command_line.push_error(crate::t!("LOFT could not create a complete display. The source sections were preserved.").as_ref()); return Task::none(); } else { - if delete_sources { self.tabs[i].scene.erase_entities(&sources); } + let mut consumed = if delete_sections { + sources.clone() + } else { + Vec::new() + }; + if delete_auxiliary { + consumed.extend(guides.iter().copied()); + consumed.extend(path); + } + consumed.sort_unstable_by_key(|handle| handle.value()); + consumed.dedup(); + self.tabs[i].scene.erase_entities(&consumed); self.tabs[i].scene.deselect_all(); self.tabs[i].scene.select_entity(created, true); self.tabs[i].dirty = true; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index a17d1a9cb..6807abfa1 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1113,7 +1113,8 @@ impl OpenCADStudio { use acadrust::entities::Region; use acadrust::types::Vector3; let mut regions = Vec::new(); - for (_, e) in self.tabs[i].scene.selected_entities().iter() { + let mut sources = Vec::new(); + for (handle, e) in self.tabs[i].scene.selected_entities().iter() { let supported = matches!( e, acadrust::EntityType::LwPolyline(pl) @@ -1136,6 +1137,7 @@ impl OpenCADStudio { ); region.common.layer = self.tabs[i].active_layer.clone(); regions.push((region, body)); + sources.push(*handle); } } if regions.is_empty() { @@ -1154,6 +1156,10 @@ impl OpenCADStudio { } created.push(handle); } + if self.delete_objects != 0 { + self.tabs[i].scene.erase_entities(&sources); + self.refresh_properties(); + } self.tabs[i].dirty = true; self.command_line .push_output(crate::tf!("REGION: created {count} region(s).").as_ref()); @@ -1530,3 +1536,47 @@ impl OpenCADStudio { Some(self.finish_dispatch(cmd)) } } + +#[cfg(test)] +mod region_tests { + use crate::app::OpenCADStudio; + + #[test] + fn region_respects_delobj_and_preserves_unconverted_sources() { + for delete_sources in [false, true] { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.automation_op(r#"{"op":"run","cmd":"CIRCLE 5,5 3"}"#); + app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#); + let i = app.active_tab; + let sources: Vec<_> = app.tabs[i].scene.document.entities() + .map(|entity| { + (entity.common().handle, matches!(entity, acadrust::EntityType::Circle(_))) + }) + .collect(); + assert_eq!(sources.len(), 2); + let handles: Vec<_> = sources.iter().map(|(handle, _)| *handle).collect(); + app.tabs[i].scene.select_entities(&handles); + app.delete_objects = i16::from(delete_sources); + + let _ = app.dispatch_command("REGION"); + + for (handle, is_circle) in &sources { + assert_eq!( + app.tabs[i].scene.document.get_entity(*handle).is_some(), + !delete_sources || !is_circle, + ); + } + let region_count = app.tabs[i].scene.document.entities() + .filter(|entity| matches!(entity, acadrust::EntityType::Region(_))) + .count(); + assert_eq!(region_count, 1); + + app.automation_op(r#"{"op":"undo"}"#); + for (handle, _) in &sources { + assert!(app.tabs[i].scene.document.get_entity(*handle).is_some()); + } + assert_eq!(app.tabs[i].scene.document.entities().count(), 2); + } + } +} diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 939eb38fb..4efc41f80 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1159,6 +1159,11 @@ impl OpenCADStudio { "0" | "OFF" | "FALSE" => Some(false), _ => None, }; + let current_delete_objects = self.delete_objects; + let requested_delete_objects = (name == "DELOBJ") + .then(|| value.as_deref()?.parse::().ok()) + .flatten() + .filter(|value| (0..=3).contains(value)); let outcome: Result<(String, bool), String> = { let h = &mut self.tabs[i].scene.document.header; match name.as_str() { @@ -1633,15 +1638,18 @@ impl OpenCADStudio { } }, "DELOBJ" => match &value { - Some(v) => parse_bool(v) - .map(|b| { - h.delete_objects = b; - (format!("DELOBJ = {}", b as i32), true) + Some(v) => v + .parse::() + .ok() + .filter(|value| (0..=3).contains(value)) + .map(|value| { + (format!("DELOBJ = {value}"), true) }) - .ok_or_else(|| "SETVAR: 0 or 1 required.".into()), - None => { - Ok((format!("DELOBJ = {}", h.delete_objects as i32), false)) - } + .ok_or_else(|| "SETVAR: integer from 0 to 3 required.".into()), + None => Ok(( + format!("DELOBJ = {current_delete_objects}"), + false, + )), }, "PLINEGEN" => match &value { Some(v) => parse_bool(v) @@ -2119,6 +2127,9 @@ impl OpenCADStudio { }; match outcome { Ok((msg, changed)) => { + if let Some(value) = requested_delete_objects { + self.delete_objects = value; + } if changed { if matches!( name.as_str(), @@ -2144,6 +2155,7 @@ impl OpenCADStudio { | "GRIPHOT" | "GRIPHOVER" | "GRIPOBJLIMIT" + | "DELOBJ" ) { self.persist_settings_if_changed(); } else { @@ -3120,4 +3132,17 @@ mod tests { assert!(app.tabs[app.active_tab].active_cmd.is_none()); assert_eq!(app.commandline_fade_ms, 5000); } + + #[test] + fn delobj_accepts_values_zero_through_three() { + let mut app = fresh_app(); + + for value in 0..=3 { + let _ = app.run_command_line(&format!("SETVAR DELOBJ {value}")); + assert_eq!(app.delete_objects, value); + } + + let _ = app.run_command_line("SETVAR DELOBJ 4"); + assert_eq!(app.delete_objects, 3); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 1f34f7c8a..4a72dd08b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -347,6 +347,52 @@ pub(crate) enum TextEntryMode { FreeText, } +pub(crate) fn delobj_deletes_profiles(value: i16) -> bool { + value != 0 +} + +pub(crate) fn delobj_deletes_auxiliary(value: i16, creates_surface: bool) -> bool { + value == 2 || (value == 3 && !creates_surface) +} + +pub(crate) fn delobj_deletes_conversion_source(value: i16) -> bool { + value >= 1 +} + +#[cfg(test)] +mod delobj_policy_tests { + use super::{ + delobj_deletes_auxiliary, delobj_deletes_conversion_source, + delobj_deletes_profiles, + }; + + #[test] + fn values_zero_through_three_select_the_expected_sources() { + assert_eq!( + (0..=3).map(delobj_deletes_profiles).collect::>(), + [false, true, true, true] + ); + assert_eq!( + (0..=3) + .map(|value| delobj_deletes_auxiliary(value, true)) + .collect::>(), + [false, false, true, false] + ); + assert_eq!( + (0..=3) + .map(|value| delobj_deletes_auxiliary(value, false)) + .collect::>(), + [false, false, true, true] + ); + assert_eq!( + (0..=3) + .map(delobj_deletes_conversion_source) + .collect::>(), + [false, true, true, true] + ); + } +} + pub(super) struct OpenCADStudio { start: Instant, control: control::State, @@ -566,6 +612,8 @@ pub(super) struct OpenCADStudio { /// `MIRRTEXT`) — the next command-line entry is its new value, empty keeps /// the current one. pending_setvar: Option, + /// DELOBJ system variable (0–3), shared by every open drawing. + delete_objects: i16, /// Cursor is hovering over the UCS icon body — drives the hover highlight. ucs_icon_hover: bool, /// UCS icon is selected (clicked): its grips are shown and draggable. @@ -3424,6 +3472,7 @@ impl OpenCADStudio { block_usage_last_persist: None, awaiting_vports: false, pending_setvar: None, + delete_objects: 1, ucs_icon_hover: false, ucs_icon_selected: false, ucs_grip_drag: None, diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index d767d9005..915dd0eac 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -2293,7 +2293,9 @@ impl super::OpenCADStudio { surfaces.push(surf); } self.push_undo_snapshot(i, "CONVTOSURFACE"); - self.tabs[i].scene.erase_entities(&handles); + if crate::app::delobj_deletes_conversion_source(self.delete_objects) { + self.tabs[i].scene.erase_entities(&handles); + } let n = surfaces.len(); for surf in surfaces { self.tabs[i].scene.add_entity(EntityType::Surface(surf)); diff --git a/src/app/settings.rs b/src/app/settings.rs index fb63859e2..bdfab8ff9 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -219,6 +219,10 @@ pub struct UserSettings { /// the current creation layer/style. Registry-style, app-level preference. #[serde(default = "default_dimension_continue_mode")] pub dimension_continue_mode: i16, + /// DELOBJ: source-geometry deletion policy (0–3). Registry-style, + /// app-level preference; first-run default is 1. + #[serde(default = "default_delete_objects")] + pub delete_objects: i16, /// TEXTFILL: fill TrueType glyphs (true) or draw them hollow (false). pub textfill: bool, /// When true, saving over an existing file first copies it to a sibling @@ -275,6 +279,10 @@ fn default_clipromptlines() -> i32 { 3 } +fn default_delete_objects() -> i16 { + 1 +} + fn default_commandline_fade_ms() -> i32 { 3000 } @@ -339,6 +347,7 @@ impl Default for UserSettings { texteditmode: false, quick_dimension_snap_priority: 0, dimension_continue_mode: 1, + delete_objects: 1, textfill: true, backup_on_save: true, file_assoc_enabled: true, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 4799d1cd9..771b2603b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -392,6 +392,7 @@ impl OpenCADStudio { texteditmode: self.texteditmode, quick_dimension_snap_priority: self.quick_dimension_snap_priority, dimension_continue_mode: self.dimension_continue_mode, + delete_objects: self.delete_objects, textfill: crate::scene::text::sdf_atlas::textfill(), backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, @@ -460,6 +461,7 @@ impl OpenCADStudio { self.texteditmode = s.texteditmode; self.quick_dimension_snap_priority = s.quick_dimension_snap_priority.min(1); self.dimension_continue_mode = s.dimension_continue_mode.clamp(0, 1); + self.delete_objects = s.delete_objects.clamp(0, 3); crate::scene::text::sdf_atlas::set_textfill(s.textfill); self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; From cf70d609444be30f7af998a1b0de4d3e5fcc5465 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 9 Sep 2026 21:58:38 +0300 Subject: [PATCH 02/53] fix(site): consolidate homepage search signals --- index.html | 1 + scripts/build-site.py | 21 ++++++++++++++++----- scripts/test_site.py | 13 ++++++++++--- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/index.html b/index.html index 1e0216af1..b8b83ce5d 100644 --- a/index.html +++ b/index.html @@ -28,6 +28,7 @@ + {{website_schema}} diff --git a/scripts/build-site.py b/scripts/build-site.py index 66afa6aff..98ab188c1 100644 --- a/scripts/build-site.py +++ b/scripts/build-site.py @@ -18,6 +18,10 @@ def locale_path(locale): return f"/{locale}/" +def public_path(locale): + return "/" if locale == "en-US" else locale_path(locale) + + def load_catalogs(): catalogs = {p.stem: json.loads(p.read_text()) for p in sorted((ROOT / "site/locales").glob("*.json"))} supported = {p.parent.name for p in (ROOT / "locales").glob("*/opencadstudio.ftl")} @@ -65,14 +69,14 @@ def build(output): script = "const SITE_LOCALES = " + json.dumps(list(catalogs)) + ";\n" + (ROOT / "site/site.js").read_text() (output / "site.js").write_text(script, encoding="utf-8") js_version = hashlib.sha256(script.encode()).hexdigest()[:12] - alternates = '\n'.join(f'' for lang in catalogs) + alternates = '\n'.join(f'' for lang in catalogs) alternates += f'\n' for locale, messages in catalogs.items(): - path = locale_path(locale) - destination = output / path.lstrip("/") + path = public_path(locale) + destination = output / locale_path(locale).lstrip("/") destination.mkdir(parents=True, exist_ok=True) language_links = '\n'.join( - f'{escape(labels["name"])}' for lang, labels in catalogs.items() ) @@ -85,6 +89,11 @@ def build(output): "downloadUrl": REPO + "/releases/latest", "installUrl": BASE + "/app/", "offers": {"@type": "Offer", "price": "0", "priceCurrency": "USD"}, } + website_schema = { + "@context": "https://schema.org", "@type": "WebSite", + "name": "Open CAD Studio", "alternateName": ["OpenCADStudio", "opencadstudio.com"], + "url": BASE + "/", + } values = {key: escape(value) for key, value in messages.items()} values.update(locale=locale, direction="rtl" if locale == "ar-SA" else "ltr", path=path, url=BASE + path, og_locale=locale.replace("-", "_"), @@ -92,6 +101,8 @@ def build(output): css_version=css_version, js_version=js_version, alternates=alternates, languages=escape(messages["languages"].format(count=len(catalogs))), language_links=language_links, + website_schema=(f'' + if locale == "en-US" else ""), schema=json.dumps(schema, ensure_ascii=False).replace("<", "\\u003c")) rendered = re.sub(r"\{\{(\w+)\}\}", lambda match: values[match[1]], template) (destination / "index.html").write_text(rendered, encoding="utf-8") @@ -101,7 +112,7 @@ def build(output): (destination / "site.webmanifest").write_text(json.dumps(localized_manifest, ensure_ascii=False, indent=2) + '\n', encoding="utf-8") for filename in ("index.html", "site.webmanifest"): shutil.copyfile(output / "en-US" / filename, output / filename) - urls = f'{BASE}/\n' + ''.join(f'{BASE}{locale_path(locale)}\n' for locale in catalogs) + urls = ''.join(f'{BASE}{public_path(locale)}\n' for locale in catalogs) (output / "sitemap.xml").write_text('\n\n' + urls + '\n') app = output / "app/index.html" diff --git a/scripts/test_site.py b/scripts/test_site.py index a13055589..a13ae82a8 100644 --- a/scripts/test_site.py +++ b/scripts/test_site.py @@ -57,7 +57,8 @@ with tempfile.TemporaryDirectory() as directory: links = [attrs for tag, attrs in page.tags if tag == 'link'] assert {link['href'] for link in links if link.get('rel') == 'icon'} == {'/favicon.ico', '/favicon.png', '/favicon.svg'} assert {link['hreflang'] for link in links if link.get('rel') == 'alternate'} == supported | {'x-default'} - assert next(link['href'] for link in links if link.get('rel') == 'canonical') == f'https://www.opencadstudio.com/{locale}/' + expected_path = '/' if locale == 'en-US' else f'/{locale}/' + assert next(link['href'] for link in links if link.get('rel') == 'canonical') == f'https://www.opencadstudio.com{expected_path}' choices = [attrs for tag, attrs in page.tags if tag == 'a' and 'hreflang' in attrs] assert {choice['hreflang'] for choice in choices} == supported assert [choice['hreflang'] for choice in choices if choice.get('aria-current') == 'page'] == [locale] @@ -74,7 +75,7 @@ with tempfile.TemporaryDirectory() as directory: path = output / urlsplit(target).path.lstrip('/') assert path.exists(), (locale, target) manifest = json.loads((output / locale / 'site.webmanifest').read_text()) - assert manifest['lang'] == locale and manifest['start_url'] == f'/{locale}/' + assert manifest['lang'] == locale and manifest['start_url'] == expected_path for icon in manifest['icons']: assert (output / icon['src'].lstrip('/')).exists() if locale != 'en-US': @@ -87,7 +88,13 @@ with tempfile.TemporaryDirectory() as directory: assert icon == '/favicon.svg' assert (output / icon.lstrip('/')).read_bytes() == (ROOT / 'site/favicon.svg').read_bytes() sitemap = ET.parse(output / 'sitemap.xml') - assert len(sitemap.getroot()) == len(supported) + 1 + locations = {url[0].text for url in sitemap.getroot()} + assert len(locations) == len(supported) + assert 'https://www.opencadstudio.com/' in locations + assert 'https://www.opencadstudio.com/en-US/' not in locations assert (output / 'index.html').read_bytes() == (output / 'en-US/index.html').read_bytes() + homepage_schemas = [json.loads(value) for value in re.findall(r'', (output / 'index.html').read_text())] + website = next(schema for schema in homepage_schemas if schema['@type'] == 'WebSite') + assert website['url'] == 'https://www.opencadstudio.com/' and 'OpenCADStudio' in website['alternateName'] subprocess.run(['node', 'scripts/test_site.cjs', str(output / 'site.js')], cwd=ROOT, check=True) print(f'All {len(supported)} translations, metadata, local links, charts, manifests and icons passed') From 6280ae6316b7ca8e38f9ea288a2c12fda4088225 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 9 Sep 2026 22:05:56 +0300 Subject: [PATCH 03/53] fix(ci): keep Pages validation site-scoped --- scripts/test_site.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/scripts/test_site.py b/scripts/test_site.py index a13ae82a8..727bf6be6 100644 --- a/scripts/test_site.py +++ b/scripts/test_site.py @@ -12,9 +12,6 @@ from urllib.parse import urlsplit import xml.etree.ElementTree as ET ROOT = Path(__file__).resolve().parents[1] -subprocess.run(['python3', 'scripts/test_locales.py'], cwd=ROOT, check=True) -subprocess.run(['python3', 'scripts/export-locales.py', '--check'], cwd=ROOT, check=True) -subprocess.run(['node', 'scripts/test_web_locales.cjs'], cwd=ROOT, check=True) class Page(HTMLParser): @@ -49,7 +46,7 @@ with tempfile.TemporaryDirectory() as directory: svg = charts.render_svg('HakanSeven12/OpenCADStudio', [date], [('v1', date, 10)], 'dark', chart, messages) tree = ET.fromstring(svg) assert tree.find('{http://www.w3.org/2000/svg}title').text == messages['stars' if chart == 'stars' else 'downloads'] - (output / locale / f'{chart[:-1]}-history-dark.svg').write_text(svg) + (output / ('' if locale == 'en-US' else locale) / f'{chart[:-1]}-history-dark.svg').write_text(svg) html = (output / locale / 'index.html').read_text() assert '{{' not in html and '{count}' not in html, locale page = Page(html) From b1d2ac2744ab1a790f6bbce205a9269bcec65c9b Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:18:23 +0300 Subject: [PATCH 04/53] Give the Options dialog a tab rail that can hold more pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog laid its pages out as a row of buttons across the top, which is what AutoCAD does and what fits while there are four of them. The pages this series adds take it to nine, and the names are translated into 21 languages — a row of nine German or Russian page names does not fit at any dialog width worth having. The rail is now a vertical list down the left, the dialog is 880×620 rather than 540×560, and the content pane scrolls on its own. Which page was open is now remembered. With four pages, reopening on General was a shrug; with nine it means hunting for the one you were last in. The ribbon's Options tool has existed all along — `ToolDef`, icon and all — but was marked `#[allow(dead_code)]` and never added to a group, so the dialog could only be reached by typing OPTIONS or from the start page. It now sits in Manage ▸ Application, beside About. No settings change in this commit; it is only the shell the rest needs. Co-Authored-By: Claude Opus 5 --- src/app/settings.rs | 3 +++ src/app/update/file.rs | 2 ++ src/app/view/modal.rs | 4 +-- src/modules/manage/mod.rs | 8 +++++- src/modules/manage/options.rs | 1 - src/ui/window/options.rs | 49 +++++++++++++++++++++++++---------- 6 files changed, 49 insertions(+), 18 deletions(-) diff --git a/src/app/settings.rs b/src/app/settings.rs index bdfab8ff9..c66b0c1bb 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -170,6 +170,8 @@ pub struct UserSettings { /// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at /// all. 0 means no limit. The drawing header carries no slot for it. pub grip_object_limit: i32, + /// Which Options page was showing when the dialog was last closed. + pub options_tab: crate::ui::window::options::OptionsTab, /// CURSORTYPE: crosshair or the platform pointer over the drawing. pub cursor_type: CursorType, /// Explicit crosshair RGB. `None` keeps automatic background contrast. @@ -325,6 +327,7 @@ impl Default for UserSettings { zoom_factor: 60, cursor_size: 5, pick_box: 3, + options_tab: crate::ui::window::options::OptionsTab::General, double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 771b2603b..bc4c020cd 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -365,6 +365,7 @@ impl OpenCADStudio { zoom_factor: self.zoom_factor, cursor_size: self.cursor_size, pick_box: self.pick_box, + options_tab: self.options_tab, double_click_block_refedit: self.double_click_block_refedit, double_click_block_attedit: self.double_click_block_attedit, grip_object_limit: self.grip_object_limit, @@ -422,6 +423,7 @@ impl OpenCADStudio { self.zoom_factor = s.zoom_factor.clamp(3, 100); self.cursor_size = s.cursor_size.clamp(1, 100); self.pick_box = s.pick_box.clamp(0, 50); + self.options_tab = s.options_tab; self.double_click_block_refedit = s.double_click_block_refedit; self.double_click_block_attedit = s.double_click_block_attedit; self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 9d116889e..0c27f9278 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -175,8 +175,8 @@ impl OpenCADStudio { } super::super::ModalKind::Options => sized_flow( ex, - 540, - 560, + 880, + 620, |flow| { crate::ui::window::options::view_window( &self.default_save_format, diff --git a/src/modules/manage/mod.rs b/src/modules/manage/mod.rs index f1a66e7aa..48fda655e 100644 --- a/src/modules/manage/mod.rs +++ b/src/modules/manage/mod.rs @@ -79,7 +79,13 @@ impl CadModule for ManageModule { // ── Application ─────────────────────────────────────────────────── RibbonGroup { title: "Application", - tools: vec![RibbonItem::LargeTool(about::tool())], + tools: vec![ + // The tool and its icon already existed but were never + // added to a group, so the dialog could only be reached + // by typing OPTIONS or from the start page. + RibbonItem::LargeTool(options::tool()), + RibbonItem::LargeTool(about::tool()), + ], }, ] }) diff --git a/src/modules/manage/options.rs b/src/modules/manage/options.rs index 615f31d0a..75bea9afc 100644 --- a/src/modules/manage/options.rs +++ b/src/modules/manage/options.rs @@ -1,5 +1,4 @@ use crate::modules::{IconKind, ModuleEvent, ToolDef}; -#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "OPTIONS", diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 7f3aa32d6..86325e844 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -7,7 +7,21 @@ use iced::widget::{ use iced::{Background, Border, Element, Fill, Theme}; use std::fmt; -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +/// Width of the vertical tab rail. Wide enough for the longest translated +/// page name; German and Russian are the ones that set it. +const TAB_RAIL_WIDTH: f32 = 178.0; +/// The dialog's own size when the modal is laid out intrinsically. +const DIALOG_WIDTH: f32 = 880.0; +const DIALOG_HEIGHT: f32 = 620.0; + +/// Which page of the Options dialog is showing. +/// +/// Persisted: with nine pages, reopening on General every time means hunting +/// for the one you were last in. +#[derive( + Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize, +)] +#[serde(rename_all = "kebab-case")] pub enum OptionsTab { #[default] General, @@ -841,38 +855,45 @@ pub fn view_window<'a>( OptionsTab::Drawing => drawing.into(), }; + // A vertical rail rather than a horizontal strip: the tab names are + // translated into 21 languages, and a row of them stops fitting long + // before the list of pages is complete. let tab_button = |label, tab| { let selected = active_tab == tab; - button(text(label).size(12)) + button(text(label).size(12.5)) .on_press(Message::OptionsTabChanged(tab)) - .padding([6, 14]) - .style(if selected { button::primary } else { button::secondary }) + .padding([7, 12]) + .width(Fill) + .style(if selected { button::primary } else { button::text }) }; - let tabs = row![ + let tabs = column![ tab_button(crate::t!("General"), OptionsTab::General), tab_button(crate::t!("Display"), OptionsTab::Display), tab_button(crate::t!("Selection"), OptionsTab::Selection), tab_button(crate::t!("Drawing"), OptionsTab::Drawing), ] - .spacing(6); + .spacing(2) + .width(iced::Length::Fixed(TAB_RAIL_WIDTH)); - let body = column![ - tabs, - Space::new().height(12), + let pane = column![ // Keep the scrollbar in its own lane instead of floating over the // controls at the trailing edge of the Options content. - scrollable(content).spacing(8).height(sizing.height), + scrollable(content).spacing(8).height(Fill), Space::new().height(12), - row![Space::new().width(sizing.width), close], + row![Space::new().width(Fill), close], ] - .width(sizing.width) + .width(Fill) .height(sizing.height); + let body = row![tabs, Space::new().width(18), pane] + .width(sizing.width) + .height(sizing.height); + let intrinsic = sizing.width == crate::ui::modal::ModalSizing::INTRINSIC.width; container(body) .style(container::rounded_box) .padding([16, 18]) - .width(if intrinsic { iced::Length::Fixed(540.0) } else { sizing.width }) - .height(if intrinsic { iced::Length::Fixed(560.0) } else { sizing.height }) + .width(if intrinsic { iced::Length::Fixed(DIALOG_WIDTH) } else { sizing.width }) + .height(if intrinsic { iced::Length::Fixed(DIALOG_HEIGHT) } else { sizing.height }) .into() } From ec8d733857a020ed7ca9a1dc1b6c890e41d346ea Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:22:36 +0300 Subject: [PATCH 05/53] Give saving its own page, with the two settings that had no control General had become three unrelated controls: a language picker, a save format and a file association. The last two move to an Open and Save page, where they join the two saving preferences that have never had a widget. Automatic save has worked since the SAVETIME command was added and is persisted; the only way to change the interval was to type it. Same for the `.bak` copy from #205 and its ISAVEBAK command. Both are settings people expect to find in a dialog, and neither was in one. The page also carries a button through to the Plot dialog. Options holds the preferences; the plot device, paper and plot styles stay where they already work well. Note that the modal stack shows one dialog at a time, so the button replaces Options rather than stacking on it. Co-Authored-By: Claude Opus 5 --- src/app/mod.rs | 4 +++ src/app/update/mod.rs | 12 +++++++ src/app/view/modal.rs | 4 +++ src/ui/window/options.rs | 78 ++++++++++++++++++++++++++++++++++++++-- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 7ecbe186a..9a6eda5f0 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2007,6 +2007,10 @@ pub enum Message { ShiftToAddToggled(bool), /// Toggle press-and-drag drawing a rectangle instead of a lasso (PICKDRAG). PickDragRectToggled(bool), + /// Change the automatic-save interval in minutes; 0 disables it (SAVETIME). + SaveTimeChanged(i32), + /// Toggle keeping a `.bak` copy when overwriting a drawing (ISAVEBAK). + BackupOnSaveChanged(bool), /// Restore Model Space display/canvas appearance to defaults. RestoreModelSpaceDisplayDefaults, /// Restore Selection visual effect settings to defaults. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index af5910dde..8f29e3ae8 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6188,6 +6188,18 @@ impl OpenCADStudio { Task::none() } + Message::SaveTimeChanged(minutes) => { + self.savetime_min = minutes.max(0); + self.persist_settings_if_changed(); + Task::none() + } + + Message::BackupOnSaveChanged(enabled) => { + self.backup_on_save = enabled; + self.persist_settings_if_changed(); + Task::none() + } + Message::PickDragRectToggled(rectangle) => { self.pick_drag_rect = rectangle; self.persist_settings_if_changed(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 0c27f9278..ebf081925 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -192,6 +192,10 @@ impl OpenCADStudio { pick_drag_rect: self.pick_drag_rect, grip_object_limit: self.grip_object_limit, }, + crate::ui::window::options::AppPrefs { + savetime_min: self.savetime_min, + backup_on_save: self.backup_on_save, + }, self.double_click_block_refedit, self.double_click_block_attedit, self.cursor_type, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 86325e844..4b674795c 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -25,11 +25,25 @@ const DIALOG_HEIGHT: f32 = 620.0; pub enum OptionsTab { #[default] General, + OpenAndSave, Display, Selection, Drawing, } +/// Application preferences the dialog reads that are plain scalars on the app. +/// +/// Gathered rather than passed one by one: `view_window` already carries a +/// long positional list, and several of these are adjacent `bool`s that would +/// swap silently at the call site. +#[derive(Clone, Copy)] +pub struct AppPrefs { + /// SAVETIME, minutes between recovery saves; 0 disables. + pub savetime_min: i32, + /// ISAVEBAK: keep a `.bak` when overwriting. + pub backup_on_save: bool, +} + /// The Selection-card settings that live on `UserSettings` rather than on the /// model-space theme. /// @@ -70,6 +84,7 @@ pub fn view_window<'a>( active_tab: OptionsTab, cursor_size: i32, selection: SelectionPrefs, + prefs: AppPrefs, double_click_block_refedit: bool, double_click_block_attedit: bool, cursor_type: CursorType, @@ -187,8 +202,18 @@ pub fn view_window<'a>( ] .spacing(12) .align_y(iced::Center), - Space::new().height(22), - text(crate::tr!("options", "open-save-section")).size(15), + ] + .spacing(0) + .width(sizing.width); + + + // Saving preferences, gathered onto one page. The format and the file + // association were on General, which had become a page of three unrelated + // controls; the autosave interval and the backup toggle have existed since + // #205 with no control at all. + let open_and_save = column![ + text(crate::t!("Open and Save")).size(15), + Space::new().height(10), Space::new().height(10), row![ text(crate::tr!("options", "default-save-format-label")).size(12).width(150), @@ -222,6 +247,53 @@ pub fn view_window<'a>( )) .size(11) .width(sizing.width), + Space::new().height(22), + text(crate::t!("File Safety")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Automatic save")).size(12).width(150), + slider(0..=120, prefs.savetime_min.clamp(0, 120), Message::SaveTimeChanged) + .step(1) + .width(Fill), + text(if prefs.savetime_min <= 0 { + crate::t!("Off").into_owned() + } else { + format!("{} min", prefs.savetime_min) + }) + .size(11) + .width(52), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Minutes between recovery saves to a .sv$ file; 0 turns it off (SAVETIME)." + )) + .size(11) + .width(sizing.width), + Space::new().height(14), + row![ + iced::widget::checkbox(prefs.backup_on_save) + .on_toggle(Message::BackupOnSaveChanged) + .size(15), + text(crate::t!("Keep a .bak copy when overwriting a drawing (ISAVEBAK)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(22), + text(crate::t!("Plotting")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Plot device, paper, scale and plot styles")) + .size(12) + .width(Fill), + button(text(crate::t!("Plot and Page Setup…")).size(11)) + .on_press(Message::PlotDialogOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), ] .spacing(0) .width(sizing.width); @@ -852,6 +924,7 @@ pub fn view_window<'a>( OptionsTab::General => general.into(), OptionsTab::Display => display_element.into(), OptionsTab::Selection => selection.into(), + OptionsTab::OpenAndSave => open_and_save.into(), OptionsTab::Drawing => drawing.into(), }; @@ -868,6 +941,7 @@ pub fn view_window<'a>( }; let tabs = column![ tab_button(crate::t!("General"), OptionsTab::General), + tab_button(crate::t!("Open and Save"), OptionsTab::OpenAndSave), tab_button(crate::t!("Display"), OptionsTab::Display), tab_button(crate::t!("Selection"), OptionsTab::Selection), tab_button(crate::t!("Drawing"), OptionsTab::Drawing), From a0027a8977f982395788fcdf4cc3db0cae57d578 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:26:20 +0300 Subject: [PATCH 06/53] Put text fill and the command-line preferences on the Display page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three settings that work, persist, and could only be reached by typing their name: TEXTFILL, CLIPROMPTLINES and COMMANDLINEFADETIME. The last two govern how much of the command window is prompt and how long its history lingers — the kind of thing people fiddle with once and then leave, which is exactly what a settings dialog is for. One fix rides along. TEXTFILL is a process-global, so changing it invalidates the text in every open drawing; the SETVAR path rebuilt only the active tab, leaving the others rendering the previous setting until an unrelated edit happened to rebuild them. Both paths now go through `invalidate_text_everywhere`, so the checkbox and the command cannot disagree. Co-Authored-By: Claude Opus 5 --- src/app/commands/styleprops.rs | 4 +- src/app/mod.rs | 6 +++ src/app/update/file.rs | 11 +++++ src/app/update/mod.rs | 23 +++++++++++ src/app/view/modal.rs | 3 ++ src/ui/window/options.rs | 75 ++++++++++++++++++++++++++++++++++ 6 files changed, 119 insertions(+), 3 deletions(-) diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 4efc41f80..12128fa88 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -2184,9 +2184,7 @@ impl OpenCADStudio { // TEXTFILL reset the glyph atlas; re-tessellate so text picks // up the re-baked filled / hollow tiles. if name == "TEXTFILL" { - self.tabs[i] - .scene - .invalidate_text_geometry_dependencies(); + self.invalidate_text_everywhere(); } // LTSCALE scales the dash pattern baked into every wire, and // PDMODE / PDSIZE decide the point glyph built at tessellation diff --git a/src/app/mod.rs b/src/app/mod.rs index 9a6eda5f0..65c2f715b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2011,6 +2011,12 @@ pub enum Message { SaveTimeChanged(i32), /// Toggle keeping a `.bak` copy when overwriting a drawing (ISAVEBAK). BackupOnSaveChanged(bool), + /// Toggle filled TrueType glyphs (TEXTFILL). + TextFillChanged(bool), + /// Change how many prompt lines sit above the command window (CLIPROMPTLINES). + ClipromptLinesChanged(i32), + /// Change how long command-line history lines stay visible (COMMANDLINEFADETIME). + CommandLineFadeChanged(i32), /// Restore Model Space display/canvas appearance to defaults. RestoreModelSpaceDisplayDefaults, /// Restore Selection visual effect settings to defaults. diff --git a/src/app/update/file.rs b/src/app/update/file.rs index bc4c020cd..61921204a 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -355,6 +355,17 @@ impl OpenCADStudio { } } + /// Rebuild text geometry in every open drawing. + /// + /// `TEXTFILL` is a process-global, so a change to it invalidates the text + /// in every tab. Doing only the active one leaves the others rendering the + /// previous setting until some unrelated edit happens to rebuild them. + pub(in crate::app) fn invalidate_text_everywhere(&mut self) { + for tab in &mut self.tabs { + tab.scene.invalidate_text_geometry_dependencies(); + } + } + /// Snapshot the persisted UI preferences from live state. pub(in crate::app) fn current_settings(&self) -> crate::app::settings::UserSettings { crate::app::settings::UserSettings { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 8f29e3ae8..9781b22f3 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6200,6 +6200,29 @@ impl OpenCADStudio { Task::none() } + Message::TextFillChanged(filled) => { + crate::scene::text::sdf_atlas::set_textfill(filled); + self.invalidate_text_everywhere(); + self.persist_settings_if_changed(); + Task::none() + } + + Message::ClipromptLinesChanged(lines) => { + let lines = crate::app::settings::clamp_clipromptlines(lines); + self.cliprompt_lines = lines; + self.command_line.set_cliprompt_lines(lines.clamp(0, 50) as u8); + self.persist_settings_if_changed(); + Task::none() + } + + Message::CommandLineFadeChanged(ms) => { + let ms = crate::app::settings::clamp_commandline_fade_ms(ms); + self.commandline_fade_ms = ms; + self.command_line.set_commandline_fade_ms(ms.max(0) as u32); + self.persist_settings_if_changed(); + Task::none() + } + Message::PickDragRectToggled(rectangle) => { self.pick_drag_rect = rectangle; self.persist_settings_if_changed(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index ebf081925..50da6adfb 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -195,6 +195,9 @@ impl OpenCADStudio { crate::ui::window::options::AppPrefs { savetime_min: self.savetime_min, backup_on_save: self.backup_on_save, + textfill: crate::scene::text::sdf_atlas::textfill(), + cliprompt_lines: self.cliprompt_lines, + commandline_fade_ms: self.commandline_fade_ms, }, self.double_click_block_refedit, self.double_click_block_attedit, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 4b674795c..35e888006 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -42,6 +42,12 @@ pub struct AppPrefs { pub savetime_min: i32, /// ISAVEBAK: keep a `.bak` when overwriting. pub backup_on_save: bool, + /// TEXTFILL: fill TrueType glyphs rather than drawing them hollow. + pub textfill: bool, + /// CLIPROMPTLINES: prompt lines shown above the command window. + pub cliprompt_lines: i32, + /// COMMANDLINEFADETIME: how long overlay history lines stay visible, in ms. + pub commandline_fade_ms: i32, } /// The Selection-card settings that live on `UserSettings` rather than on the @@ -642,6 +648,75 @@ pub fn view_window<'a>( text(crate::t!("Changes the on-screen width in Model without affecting plotted output.")) .size(11) .width(sizing.width), + ) + .push(Space::new().height(14)) + .push( + row![ + iced::widget::checkbox(prefs.textfill) + .on_toggle(Message::TextFillChanged) + .size(15), + text(crate::t!("Fill TrueType glyphs (TEXTFILL)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ) + .push(Space::new().height(24)) + .push(text(crate::t!("Command Line")).size(15)) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Prompt lines")).size(12).width(140), + slider( + 0..=50, + prefs.cliprompt_lines.clamp(0, 50), + Message::ClipromptLinesChanged, + ) + .step(1) + .width(Fill), + text(prefs.cliprompt_lines.clamp(0, 50).to_string()) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(6)) + .push( + text(crate::t!( + "Temporary prompt lines shown above the command window (CLIPROMPTLINES)." + )) + .size(11) + .width(sizing.width), + ) + .push(Space::new().height(12)) + .push( + row![ + text(crate::t!("History fade time")).size(12).width(140), + slider( + 0..=60000, + prefs.commandline_fade_ms.clamp(0, 60000), + Message::CommandLineFadeChanged, + ) + .step(250) + .width(Fill), + text(if prefs.commandline_fade_ms <= 0 { + crate::t!("Off").into_owned() + } else { + format!("{:.1} s", prefs.commandline_fade_ms as f32 / 1000.0) + }) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(6)) + .push( + text(crate::t!( + "How long overlay history lines stay visible; 0 skips them (COMMANDLINEFADETIME)." + )) + .size(11) + .width(sizing.width), ); let display_element = display.spacing(0).width(sizing.width); From 9c3f3625dada14cafb50d7706013bfffbd615588 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:29:48 +0300 Subject: [PATCH 07/53] Add a User Preferences page for six command-only settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zoom direction and factor, whether TEXTEDIT ends after one object, whether continued dimensions inherit the base dimension's style, which points QDIM measures from, and which annotative objects pick up a newly set scale. All six work today; none had a control. Two of them needed their meaning worked out rather than copied. ANNOAUTOSCALE stores the mode in the magnitude and on/off in the sign, and the four modes are not documented anywhere in the app — the command prompt just says `[-4..4]`. Reading `add_annotation_scale_to_objects` gives them: 1 skips layers that are off, frozen, locked or frozen in the viewport, 2 keeps the locked ones, 3 skips only locked, 4 takes everything. The dropdown names them instead of showing bare numbers, and choosing Off negates rather than zeroing, so the mode survives being switched off and back on — which is the convention the status-bar pill already follows. The QDIM origin priority had no system variable at all; it could only be reached through a keyword inside the running command. Drawing units stay in their own dialog; this page links to it. Co-Authored-By: Claude Opus 5 --- src/app/mod.rs | 12 +++ src/app/update/mod.rs | 43 +++++++++++ src/app/view/modal.rs | 6 ++ src/ui/window/options.rs | 157 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 218 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 65c2f715b..357a8a73e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2017,6 +2017,18 @@ pub enum Message { ClipromptLinesChanged(i32), /// Change how long command-line history lines stay visible (COMMANDLINEFADETIME). CommandLineFadeChanged(i32), + /// Toggle reversing the mouse-wheel zoom direction (ZOOMWHEEL). + ZoomWheelReversedChanged(bool), + /// Change how far one wheel notch zooms (ZOOMFACTOR, 3..=100). + ZoomFactorChanged(i32), + /// Toggle TEXTEDIT ending after one object (TEXTEDITMODE). + TextEditModeChanged(bool), + /// Toggle continued dimensions inheriting the base style (DIMCONTINUEMODE). + DimContinueModeChanged(bool), + /// Change which points QDIM measures from (0 endpoints, 1 intersections). + QdimSnapPriorityChanged(u8), + /// Change which annotative objects pick up a new scale (ANNOAUTOSCALE). + AnnoAutoScaleChanged(i8), /// Restore Model Space display/canvas appearance to defaults. RestoreModelSpaceDisplayDefaults, /// Restore Selection visual effect settings to defaults. diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 9781b22f3..4b83b5732 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6223,6 +6223,49 @@ impl OpenCADStudio { Task::none() } + Message::ZoomWheelReversedChanged(reversed) => { + self.zoom_wheel_reversed = reversed; + self.persist_settings_if_changed(); + Task::none() + } + + Message::ZoomFactorChanged(factor) => { + self.zoom_factor = factor.clamp(3, 100); + self.persist_settings_if_changed(); + Task::none() + } + + Message::TextEditModeChanged(single) => { + self.texteditmode = single; + self.persist_settings_if_changed(); + Task::none() + } + + Message::DimContinueModeChanged(inherit) => { + self.dimension_continue_mode = i16::from(inherit); + self.persist_settings_if_changed(); + Task::none() + } + + Message::QdimSnapPriorityChanged(priority) => { + self.quick_dimension_snap_priority = priority.min(1); + self.persist_settings_if_changed(); + Task::none() + } + + // The sign carries on/off and the magnitude the mode, so switching + // off and back on has to return to the mode that was chosen — the + // same convention the status-bar pill uses when it negates. + Message::AnnoAutoScaleChanged(mode) => { + self.annotation_auto_scale = if mode == 0 { + -self.annotation_auto_scale.abs().max(1) + } else { + mode.clamp(1, 4) + }; + self.persist_settings_if_changed(); + Task::none() + } + Message::PickDragRectToggled(rectangle) => { self.pick_drag_rect = rectangle; self.persist_settings_if_changed(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 50da6adfb..3f650f643 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -198,6 +198,12 @@ impl OpenCADStudio { textfill: crate::scene::text::sdf_atlas::textfill(), cliprompt_lines: self.cliprompt_lines, commandline_fade_ms: self.commandline_fade_ms, + zoom_wheel_reversed: self.zoom_wheel_reversed, + zoom_factor: self.zoom_factor, + texteditmode: self.texteditmode, + dimension_continue_mode: self.dimension_continue_mode, + qdim_snap_priority: self.quick_dimension_snap_priority, + annotation_auto_scale: self.annotation_auto_scale, }, self.double_click_block_refedit, self.double_click_block_attedit, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 35e888006..14bbb2de6 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -28,6 +28,7 @@ pub enum OptionsTab { OpenAndSave, Display, Selection, + UserPreferences, Drawing, } @@ -48,6 +49,19 @@ pub struct AppPrefs { pub cliprompt_lines: i32, /// COMMANDLINEFADETIME: how long overlay history lines stay visible, in ms. pub commandline_fade_ms: i32, + /// ZOOMWHEEL: reverse the mouse-wheel zoom direction. + pub zoom_wheel_reversed: bool, + /// ZOOMFACTOR, 3..=100. + pub zoom_factor: i32, + /// TEXTEDITMODE: TEXTEDIT keeps prompting for the next object. + pub texteditmode: bool, + /// DIMCONTINUEMODE: continued dimensions inherit the base dimension's style. + pub dimension_continue_mode: i16, + /// QDIM extension-origin priority: 0 endpoints, 1 intersections. + pub qdim_snap_priority: u8, + /// ANNOAUTOSCALE, -4..=4. The sign is on/off; the magnitude selects which + /// objects a newly added scale reaches. + pub annotation_auto_scale: i8, } /// The Selection-card settings that live on `UserSettings` rather than on the @@ -995,11 +1009,153 @@ pub fn view_window<'a>( .spacing(0) .width(sizing.width); + + // ANNOAUTOSCALE's magnitude decides which objects a newly added scale + // reaches — 1 skips layers that are off, frozen, locked or frozen in the + // viewport, 2 keeps locked ones, 3 skips only locked, 4 takes everything. + // The sign is the on/off the status-bar pill flips, so the mode survives + // being switched off and back on. + let auto_scale_options = [ + (0i8, "Off"), + (1, "Skip objects on layers that are off, frozen or locked"), + (2, "Skip objects on layers that are off or frozen"), + (3, "Skip objects on locked layers"), + (4, "All annotative objects"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: crate::t!(label).into_owned(), + }) + .collect::>(); + let selected_auto_scale = auto_scale_options + .iter() + .find(|choice| choice.value == prefs.annotation_auto_scale.max(0)) + .cloned(); + + let qdim_options = [ + (0u8, "Endpoints"), + (1, "Intersections"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: crate::t!(label).into_owned(), + }) + .collect::>(); + let selected_qdim = qdim_options + .iter() + .find(|choice| choice.value == prefs.qdim_snap_priority) + .cloned(); + + let user_prefs = column![ + text(crate::t!("User Preferences")).size(15), + Space::new().height(10), + text(crate::t!("Zoom")).size(15), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.zoom_wheel_reversed) + .on_toggle(Message::ZoomWheelReversedChanged) + .size(15), + text(crate::t!("Reverse mouse wheel zoom (ZOOMWHEEL)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(12), + row![ + text(crate::t!("Zoom factor")).size(12).width(150), + slider(3..=100, prefs.zoom_factor.clamp(3, 100), Message::ZoomFactorChanged) + .step(1) + .width(Fill), + text(prefs.zoom_factor.clamp(3, 100).to_string()).size(11).width(44), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!("How far one wheel notch zooms (ZOOMFACTOR).")) + .size(11) + .width(sizing.width), + Space::new().height(24), + text(crate::t!("Text and Dimensions")).size(15), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.texteditmode) + .on_toggle(Message::TextEditModeChanged) + .size(15), + text(crate::t!("TEXTEDIT edits one object and ends (TEXTEDITMODE)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(12), + row![ + iced::widget::checkbox(prefs.dimension_continue_mode == 1) + .on_toggle(Message::DimContinueModeChanged) + .size(15), + text(crate::t!( + "Continued dimensions inherit the base dimension's layer and style (DIMCONTINUEMODE)" + )) + .size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(12), + row![ + text(crate::t!("QDIM origin priority")).size(12).width(150), + iced::widget::pick_list(selected_qdim, qdim_options, |choice| choice.label.clone()) + .on_select(|choice| Message::QdimSnapPriorityChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Which points QDIM measures from. Also settable inside the command." + )) + .size(11) + .width(sizing.width), + Space::new().height(24), + text(crate::t!("Annotation")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Add scales automatically")).size(12).width(150), + iced::widget::pick_list(selected_auto_scale, auto_scale_options, |choice| { + choice.label.clone() + }) + .on_select(|choice| Message::AnnoAutoScaleChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Which annotative objects pick up a newly set annotation scale (ANNOAUTOSCALE)." + )) + .size(11) + .width(sizing.width), + Space::new().height(24), + text(crate::t!("Drawing Units")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Length, angle and insertion units")) + .size(12) + .width(Fill), + button(text(crate::t!("Drawing Units…")).size(11)) + .on_press(Message::OpenDrawingUnits) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + ] + .spacing(0) + .width(sizing.width); + let content: Element<'a, Message> = match active_tab { OptionsTab::General => general.into(), OptionsTab::Display => display_element.into(), OptionsTab::Selection => selection.into(), OptionsTab::OpenAndSave => open_and_save.into(), + OptionsTab::UserPreferences => user_prefs.into(), OptionsTab::Drawing => drawing.into(), }; @@ -1019,6 +1175,7 @@ pub fn view_window<'a>( tab_button(crate::t!("Open and Save"), OptionsTab::OpenAndSave), tab_button(crate::t!("Display"), OptionsTab::Display), tab_button(crate::t!("Selection"), OptionsTab::Selection), + tab_button(crate::t!("User Preferences"), OptionsTab::UserPreferences), tab_button(crate::t!("Drawing"), OptionsTab::Drawing), ] .spacing(2) From b5f4b4d1cc992daf607be749109c279f00e58d71 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:33:19 +0300 Subject: [PATCH 08/53] Add a Drafting page for the rotation angle and the polar increment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two drafting preferences had no proper home. The Drafting Settings dialog displays the drafting rotation and offers only a Reset button — there was no way to type an angle short of `SETVAR SNAPANG`. The polar tracking increment existed in exactly one place: a pop-up hanging off the status-bar pill. The rotation is a text field over an edit buffer rather than the value itself, so clearing it to retype does not snap the crosshair to zero on the way through; the buffer is reseeded whenever the value is restored from behind it. Angles render without a trailing `.0`, matching how the polar pop-up has always shown them — same kind of number, same reader. Snap modes, grid spacing and object snap stay in Drafting Settings, where they belong; the page links to it. Co-Authored-By: Claude Opus 5 --- src/app/mod.rs | 8 +++++ src/app/settings.rs | 12 +++++++ src/app/update/file.rs | 3 ++ src/app/update/mod.rs | 21 +++++++++++ src/app/view/modal.rs | 2 ++ src/ui/window/options.rs | 77 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 357a8a73e..d880c9918 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -517,6 +517,9 @@ pub(super) struct OpenCADStudio { crosshair_color: Option<[u8; 3]>, /// Editable Options buffer for the crosshair colour. crosshair_color_input: String, + /// Edit buffer for the SNAPANG field on the Options Drafting page. Kept + /// separate from `snap_angle_deg` so a half-typed angle is not parsed. + snap_angle_input: String, /// Model-space lineweight preview scale, in percent (25..=200). lineweight_display_scale: i32, /// Isometric drafting state and active axis pair. @@ -2029,6 +2032,10 @@ pub enum Message { QdimSnapPriorityChanged(u8), /// Change which annotative objects pick up a new scale (ANNOAUTOSCALE). AnnoAutoScaleChanged(i8), + /// Edit the drafting rotation field; parsed when it holds a valid angle (SNAPANG). + SnapAngleInputChanged(String), + /// Change the polar tracking increment in degrees. + PolarIncrementChanged(f32), /// Restore Model Space display/canvas appearance to defaults. RestoreModelSpaceDisplayDefaults, /// Restore Selection visual effect settings to defaults. @@ -3429,6 +3436,7 @@ impl OpenCADStudio { cursor_type: settings::CursorType::Crosshair, crosshair_color: None, crosshair_color_input: String::new(), + snap_angle_input: "0".to_string(), lineweight_display_scale: 100, isometric_drafting: false, iso_plane: settings::IsoPlane::Left, diff --git a/src/app/settings.rs b/src/app/settings.rs index c66b0c1bb..5aded5e11 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -145,6 +145,18 @@ pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec, bool) { (modes, osmode & OSMODE_SUPPRESS == 0) } +/// Render a drafting angle without a trailing `.0`, so `22.5` but `30`. +/// +/// The polar pop-up formats its presets the same way; both are showing the +/// same kind of number to the same person. +pub fn format_snap_angle(deg: f32) -> String { + if (deg - deg.round()).abs() < 1e-4 { + format!("{}", deg.round() as i32) + } else { + format!("{deg}") + } +} + /// GRIPOBJLIMIT default: past this many selected objects, no grips are drawn. pub const DEFAULT_GRIP_OBJECT_LIMIT: i32 = 100; diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 61921204a..2803bdcf0 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -452,6 +452,9 @@ impl OpenCADStudio { } else { 0.0 }; + // The Options field edits a buffer rather than the value, so it has to + // be reseeded whenever the value is restored from behind it. + self.snap_angle_input = crate::app::settings::format_snap_angle(self.snap_angle_deg); // Ortho + running OSNAP are per-drawing (adopted from the header on // open / tab switch), not app-global, so they are not applied here. self.snapper.otrack_enabled = s.otrack; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4b83b5732..463db49af 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6266,6 +6266,27 @@ impl OpenCADStudio { Task::none() } + // Typed into freely; only committed when it parses, so clearing + // the field to retype does not snap the crosshair back to zero. + Message::SnapAngleInputChanged(value) => { + self.snap_angle_input = value; + if let Ok(angle) = self.snap_angle_input.trim().parse::() { + if angle.is_finite() { + self.snap_angle_deg = angle.rem_euclid(360.0); + self.persist_settings_if_changed(); + } + } + Task::none() + } + + Message::PolarIncrementChanged(deg) => { + if deg.is_finite() && deg > 0.0 { + self.polar_increment_deg = deg; + self.persist_settings_if_changed(); + } + Task::none() + } + Message::PickDragRectToggled(rectangle) => { self.pick_drag_rect = rectangle; self.persist_settings_if_changed(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 3f650f643..207f28454 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -204,7 +204,9 @@ impl OpenCADStudio { dimension_continue_mode: self.dimension_continue_mode, qdim_snap_priority: self.quick_dimension_snap_priority, annotation_auto_scale: self.annotation_auto_scale, + polar_increment_deg: self.polar_increment_deg, }, + &self.snap_angle_input, self.double_click_block_refedit, self.double_click_block_attedit, self.cursor_type, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 14bbb2de6..b597d1715 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -27,6 +27,7 @@ pub enum OptionsTab { General, OpenAndSave, Display, + Drafting, Selection, UserPreferences, Drawing, @@ -62,6 +63,8 @@ pub struct AppPrefs { /// ANNOAUTOSCALE, -4..=4. The sign is on/off; the magnitude selects which /// objects a newly added scale reaches. pub annotation_auto_scale: i8, + /// Polar tracking increment in degrees. + pub polar_increment_deg: f32, } /// The Selection-card settings that live on `UserSettings` rather than on the @@ -105,6 +108,7 @@ pub fn view_window<'a>( cursor_size: i32, selection: SelectionPrefs, prefs: AppPrefs, + snap_angle_input: &'a str, double_click_block_refedit: bool, double_click_block_attedit: bool, cursor_type: CursorType, @@ -1150,11 +1154,83 @@ pub fn view_window<'a>( .spacing(0) .width(sizing.width); + + // Snap modes, grid and object snap stay in the Drafting Settings dialog. + // These two have no home: Drafting Settings shows the rotation angle but + // offers only Reset, and the polar increment lives solely in a status-bar + // pop-up. + let polar_options = [90.0f32, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0, 1.0] + .into_iter() + .map(|value| Labelled { + label: format!("{}°", crate::app::settings::format_snap_angle(value)), + value, + }) + .collect::>(); + let selected_polar = polar_options + .iter() + .find(|choice| (choice.value - prefs.polar_increment_deg).abs() < 1e-4) + .cloned() + .unwrap_or_else(|| Labelled { + value: prefs.polar_increment_deg, + label: format!( + "{}°", + crate::app::settings::format_snap_angle(prefs.polar_increment_deg) + ), + }); + + let drafting = column![ + text(crate::t!("Drafting")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Drafting rotation")).size(12).width(150), + text_input("0", snap_angle_input) + .on_input(Message::SnapAngleInputChanged) + .width(110), + text(crate::t!("degrees")).size(11), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(6), + text(crate::t!( + "Rotates the crosshair and the snap grid in the active UCS (SNAPANG)." + )) + .size(11) + .width(sizing.width), + Space::new().height(14), + row![ + text(crate::t!("Polar tracking increment")).size(12).width(150), + iced::widget::pick_list(Some(selected_polar), polar_options, |choice| { + choice.label.clone() + }) + .on_select(|choice| Message::PolarIncrementChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(24), + text(crate::t!("Snap and Grid")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Snap modes, grid spacing and object snap")) + .size(12) + .width(Fill), + button(text(crate::t!("Drafting Settings…")).size(11)) + .on_press(Message::ToggleSnapPopup) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + ] + .spacing(0) + .width(sizing.width); + let content: Element<'a, Message> = match active_tab { OptionsTab::General => general.into(), OptionsTab::Display => display_element.into(), OptionsTab::Selection => selection.into(), OptionsTab::OpenAndSave => open_and_save.into(), + OptionsTab::Drafting => drafting.into(), OptionsTab::UserPreferences => user_prefs.into(), OptionsTab::Drawing => drawing.into(), }; @@ -1174,6 +1250,7 @@ pub fn view_window<'a>( tab_button(crate::t!("General"), OptionsTab::General), tab_button(crate::t!("Open and Save"), OptionsTab::OpenAndSave), tab_button(crate::t!("Display"), OptionsTab::Display), + tab_button(crate::t!("Drafting"), OptionsTab::Drafting), tab_button(crate::t!("Selection"), OptionsTab::Selection), tab_button(crate::t!("User Preferences"), OptionsTab::UserPreferences), tab_button(crate::t!("Drawing"), OptionsTab::Drawing), From 5a638f243a410dd34c32fc1b97ac55e88c2917cb Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:41:26 +0300 Subject: [PATCH 09/53] Make the view toggles survive a restart, and give them a page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ViewCube, the UCS icon, its at-origin placement and selection cycling all have commands and status-bar pills, and all four kept their value only on the app struct. Turn the ViewCube off with NAVVCUBE, restart, and it is back. Nothing failed — the setting evaporated — so a checkbox for any of them would have been a checkbox that forgets. They are persisted first, with a round-trip test, and only then given controls. The new 3D Modeling page carries the three view tools plus the drawing variables that decide how solids are tessellated: ISOLINES, DISPSILH, SURFU, SURFV, SURFTYPE, SOLIDHIST and SHOWHIST. Those are saved in the DWG rather than in preferences, so the page says so beside each group and hides them entirely when no drawing is open. Changing them also rebuilds the geometry. The SETVAR path never did, which is why the comment beside LTSCALE's invalidation exists — the same reasoning applies here, and without it a new isoline count does not appear until some unrelated edit rebuilds the solid. Selection cycling joins the Selection page next to the other two selection modes, since that is what it is. Co-Authored-By: Claude Opus 5 --- src/app/mod.rs | 22 ++++ src/app/properties.rs | 23 ++++ src/app/settings.rs | 13 +++ src/app/update/file.rs | 29 +++++ src/app/update/mod.rs | 76 ++++++++++++ src/app/view/modal.rs | 23 ++++ src/ui/window/options.rs | 241 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 427 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index d880c9918..6ee56bbe4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2036,6 +2036,28 @@ pub enum Message { SnapAngleInputChanged(String), /// Change the polar tracking increment in degrees. PolarIncrementChanged(f32), + /// Toggle the navigation cube (NAVVCUBE). + ShowViewCubeChanged(bool), + /// Toggle the UCS icon (UCSICON). + ShowUcsIconChanged(bool), + /// Toggle drawing the UCS icon at the origin (UCSICON ORigin). + UcsIconAtOriginChanged(bool), + /// Toggle selection cycling from Options; the status-bar pill toggles the same flag. + SelectionCyclingChanged(bool), + /// Change isolines per surface in the current drawing (ISOLINES). + IsolinesChanged(i16), + /// Toggle silhouette edges in the current drawing (DISPSILH). + DispSilhChanged(bool), + /// Change surface density U in the current drawing (SURFU). + SurfaceUChanged(i16), + /// Change surface density V in the current drawing (SURFV). + SurfaceVChanged(i16), + /// Change the surface type in the current drawing (SURFTYPE). + SurfaceTypeChanged(i16), + /// Toggle recording composite-solid history in the current drawing (SOLIDHIST). + SolidHistChanged(bool), + /// Change when solid history is shown in the current drawing (SHOWHIST). + ShowHistChanged(i16), /// Restore Model Space display/canvas appearance to defaults. RestoreModelSpaceDisplayDefaults, /// Restore Selection visual effect settings to defaults. diff --git a/src/app/properties.rs b/src/app/properties.rs index 8fd1faec1..bcbbd5aa3 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -4149,6 +4149,29 @@ mod grip_limit_tests { ); } + /// The ViewCube, the UCS icon and selection cycling all have commands and + /// status-bar pills, but their values lived only on the app struct: turn + /// the ViewCube off, restart, and it came straight back. Nothing failed — + /// the setting simply evaporated — which is exactly the kind of thing a + /// round-trip test catches and a person does not. + #[test] + fn the_view_toggles_survive_a_save_and_load() { + let mut app = OpenCADStudio::new_for_test(); + app.show_viewcube = false; + app.show_ucs_icon = false; + app.ucs_icon_at_origin = false; + app.selection_cycling = true; + + let saved = app.current_settings(); + let mut restored = OpenCADStudio::new_for_test(); + restored.apply_settings(&saved); + + assert!(!restored.show_viewcube, "the ViewCube must stay off"); + assert!(!restored.show_ucs_icon); + assert!(!restored.ucs_icon_at_origin); + assert!(restored.selection_cycling); + } + /// The Options card writes these four straight onto the app and relies on /// the snapshot/restore pair to carry them across a restart. Three of them /// had no UI until now and so no reason for anyone to notice if the pair diff --git a/src/app/settings.rs b/src/app/settings.rs index 5aded5e11..3032b0a60 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -184,6 +184,15 @@ pub struct UserSettings { pub grip_object_limit: i32, /// Which Options page was showing when the dialog was last closed. pub options_tab: crate::ui::window::options::OptionsTab, + /// Show the navigation cube (NAVVCUBE). + pub show_viewcube: bool, + /// Show the UCS icon (UCSICON). + pub show_ucs_icon: bool, + /// Draw the UCS icon at the origin rather than in the corner + /// (UCSICON ORigin / NOorigin). + pub ucs_icon_at_origin: bool, + /// Selection cycling: a click where objects overlap opens a picker. + pub selection_cycling: bool, /// CURSORTYPE: crosshair or the platform pointer over the drawing. pub cursor_type: CursorType, /// Explicit crosshair RGB. `None` keeps automatic background contrast. @@ -340,6 +349,10 @@ impl Default for UserSettings { cursor_size: 5, pick_box: 3, options_tab: crate::ui::window::options::OptionsTab::General, + show_viewcube: true, + show_ucs_icon: true, + ucs_icon_at_origin: true, + selection_cycling: false, double_click_block_refedit: false, double_click_block_attedit: true, grip_object_limit: DEFAULT_GRIP_OBJECT_LIMIT, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 2803bdcf0..86694f6b2 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -355,6 +355,24 @@ impl OpenCADStudio { } } + /// Write a tessellation-affecting header variable on the active drawing. + /// + /// These decide how solids and surfaces are tessellated, so the geometry + /// has to be rebuilt for the change to appear — the same reason `LTSCALE` + /// bumps it. Marking the tab modified is what makes the new value reach + /// the file. + pub(in crate::app) fn set_drawing_tessellation_var( + &mut self, + write: impl FnOnce(&mut acadrust::document::HeaderVariables), + ) { + let i = self.active_tab; + if let Some(tab) = self.tabs.get_mut(i) { + write(&mut tab.scene.document.header); + tab.scene.bump_geometry(); + tab.dirty = true; + } + } + /// Rebuild text geometry in every open drawing. /// /// `TEXTFILL` is a process-global, so a change to it invalidates the text @@ -377,6 +395,10 @@ impl OpenCADStudio { cursor_size: self.cursor_size, pick_box: self.pick_box, options_tab: self.options_tab, + show_viewcube: self.show_viewcube, + show_ucs_icon: self.show_ucs_icon, + ucs_icon_at_origin: self.ucs_icon_at_origin, + selection_cycling: self.selection_cycling, double_click_block_refedit: self.double_click_block_refedit, double_click_block_attedit: self.double_click_block_attedit, grip_object_limit: self.grip_object_limit, @@ -435,6 +457,13 @@ impl OpenCADStudio { self.cursor_size = s.cursor_size.clamp(1, 100); self.pick_box = s.pick_box.clamp(0, 50); self.options_tab = s.options_tab; + // These four drive real features with commands and status-bar pills, + // but they lived only on the app struct: turning the ViewCube off and + // restarting brought it straight back. + self.show_viewcube = s.show_viewcube; + self.show_ucs_icon = s.show_ucs_icon; + self.ucs_icon_at_origin = s.ucs_icon_at_origin; + self.selection_cycling = s.selection_cycling; self.double_click_block_refedit = s.double_click_block_refedit; self.double_click_block_attedit = s.double_click_block_attedit; self.grip_object_limit = s.grip_object_limit.clamp(0, 32767); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 463db49af..3e88cb454 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6287,6 +6287,82 @@ impl OpenCADStudio { Task::none() } + Message::ShowViewCubeChanged(show) => { + self.show_viewcube = show; + self.persist_settings_if_changed(); + Task::none() + } + + Message::ShowUcsIconChanged(show) => { + self.show_ucs_icon = show; + self.persist_settings_if_changed(); + Task::none() + } + + Message::UcsIconAtOriginChanged(at_origin) => { + self.ucs_icon_at_origin = at_origin; + self.persist_settings_if_changed(); + Task::none() + } + + // The five below are drawing variables: they go into the header of + // the active drawing and mark it modified, exactly as SETVAR does. + // The four that feed solid tessellation also bump the geometry, or + // the change is invisible until an unrelated edit rebuilds it. + Message::IsolinesChanged(value) => { + self.set_drawing_tessellation_var(|header| header.isolines = value.max(0)); + Task::none() + } + + Message::DispSilhChanged(on) => { + self.set_drawing_tessellation_var(|header| header.display_silhouette = on); + Task::none() + } + + Message::SurfaceUChanged(value) => { + self.set_drawing_tessellation_var(|header| { + header.surface_u_density = value.clamp(0, 200) + }); + Task::none() + } + + Message::SurfaceVChanged(value) => { + self.set_drawing_tessellation_var(|header| { + header.surface_v_density = value.clamp(0, 200) + }); + Task::none() + } + + Message::SurfaceTypeChanged(value) => { + self.set_drawing_tessellation_var(|header| header.surface_type = value); + Task::none() + } + + Message::SolidHistChanged(record) => { + let i = self.active_tab; + if let Some(tab) = self.tabs.get_mut(i) { + tab.scene.document.header.record_solid_history = record; + tab.dirty = true; + } + Task::none() + } + + Message::ShowHistChanged(mode) => { + let i = self.active_tab; + if let Some(tab) = self.tabs.get_mut(i) { + tab.scene.document.header.show_solid_history = mode.clamp(0, 2); + tab.scene.bump_geometry(); + tab.dirty = true; + } + Task::none() + } + + Message::SelectionCyclingChanged(on) => { + self.selection_cycling = on; + self.persist_settings_if_changed(); + Task::none() + } + Message::PickDragRectToggled(rectangle) => { self.pick_drag_rect = rectangle; self.persist_settings_if_changed(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 207f28454..e9b5278f6 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -191,6 +191,7 @@ impl OpenCADStudio { pick_add: self.pick_add, pick_drag_rect: self.pick_drag_rect, grip_object_limit: self.grip_object_limit, + selection_cycling: self.selection_cycling, }, crate::ui::window::options::AppPrefs { savetime_min: self.savetime_min, @@ -205,8 +206,30 @@ impl OpenCADStudio { qdim_snap_priority: self.quick_dimension_snap_priority, annotation_auto_scale: self.annotation_auto_scale, polar_increment_deg: self.polar_increment_deg, + show_viewcube: self.show_viewcube, + show_ucs_icon: self.show_ucs_icon, + ucs_icon_at_origin: self.ucs_icon_at_origin, }, &self.snap_angle_input, + { + let header = self + .tabs + .get(self.active_tab) + .map(|tab| &tab.scene.document.header); + crate::ui::window::options::DrawingPrefs { + available: header.is_some(), + isolines: header.map_or(4, |h| h.isolines), + display_silhouette: header + .is_some_and(|h| h.display_silhouette), + surface_u: header.map_or(6, |h| h.surface_u_density), + surface_v: header.map_or(6, |h| h.surface_v_density), + surface_type: header.map_or(6, |h| h.surface_type), + record_solid_history: header + .is_some_and(|h| h.record_solid_history), + show_solid_history: header + .map_or(1, |h| h.show_solid_history), + } + }, self.double_click_block_refedit, self.double_click_block_attedit, self.cursor_type, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index b597d1715..a367f24be 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -28,6 +28,7 @@ pub enum OptionsTab { OpenAndSave, Display, Drafting, + Modeling, Selection, UserPreferences, Drawing, @@ -65,6 +66,35 @@ pub struct AppPrefs { pub annotation_auto_scale: i8, /// Polar tracking increment in degrees. pub polar_increment_deg: f32, + /// NAVVCUBE: show the navigation cube. + pub show_viewcube: bool, + /// UCSICON: show the UCS icon. + pub show_ucs_icon: bool, + /// UCSICON ORigin: draw it at the origin rather than the corner. + pub ucs_icon_at_origin: bool, +} + +/// Values read from the current drawing's header rather than from preferences. +/// +/// These are saved in the DWG, so they follow the drawing rather than the +/// application — the page labels them as such. `available` is false when no +/// drawing is open and the controls are not drawn at all. +#[derive(Clone, Copy)] +pub struct DrawingPrefs { + pub available: bool, + /// ISOLINES. + pub isolines: i16, + /// DISPSILH. + pub display_silhouette: bool, + /// SURFU / SURFV. + pub surface_u: i16, + pub surface_v: i16, + /// SURFTYPE: 5, 6 or 8. + pub surface_type: i16, + /// SOLIDHIST. + pub record_solid_history: bool, + /// SHOWHIST, 0..=2. + pub show_solid_history: i16, } /// The Selection-card settings that live on `UserSettings` rather than on the @@ -83,6 +113,8 @@ pub struct SelectionPrefs { pub pick_drag_rect: bool, /// GRIPOBJLIMIT, 0..=32767; 0 = no limit. pub grip_object_limit: i32, + /// Selection cycling: a click where objects overlap opens a picker. + pub selection_cycling: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -109,6 +141,7 @@ pub fn view_window<'a>( selection: SelectionPrefs, prefs: AppPrefs, snap_angle_input: &'a str, + drawing_prefs: DrawingPrefs, double_click_block_refedit: bool, double_click_block_attedit: bool, cursor_type: CursorType, @@ -783,6 +816,15 @@ pub fn view_window<'a>( ] .spacing(8) .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(selection.selection_cycling) + .on_toggle(Message::SelectionCyclingChanged) + .size(15), + text(crate::t!("Clicking overlapping objects opens a picker")).size(12), + ] + .spacing(8) + .align_y(iced::Center), Space::new().height(24), row![ text(crate::t!("Visual Effect Settings")).size(15), @@ -1225,12 +1267,210 @@ pub fn view_window<'a>( .spacing(0) .width(sizing.width); + + let surface_type_options = [ + (5i16, "Quadratic B-spline"), + (6, "Cubic B-spline"), + (8, "Bezier"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: format!("{} ({value})", crate::t!(label)), + }) + .collect::>(); + let selected_surface_type = surface_type_options + .iter() + .find(|choice| choice.value == drawing_prefs.surface_type) + .cloned(); + + let show_history_options = [ + (0i16, "Never"), + (1, "As set per solid"), + (2, "Always"), + ] + .into_iter() + .map(|(value, label)| Labelled { + value, + label: crate::t!(label).into_owned(), + }) + .collect::>(); + let selected_show_history = show_history_options + .iter() + .find(|choice| choice.value == drawing_prefs.show_solid_history.clamp(0, 2)) + .cloned(); + + let mut modeling = column![ + text(crate::t!("3D Modeling")).size(15), + Space::new().height(10), + text(crate::t!("Display Tools")).size(15), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.show_viewcube) + .on_toggle(Message::ShowViewCubeChanged) + .size(15), + text(crate::t!("Show the navigation cube (NAVVCUBE)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.show_ucs_icon) + .on_toggle(Message::ShowUcsIconChanged) + .size(15), + text(crate::t!("Show the UCS icon (UCSICON)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(10), + row![ + iced::widget::checkbox(prefs.ucs_icon_at_origin) + .on_toggle(Message::UcsIconAtOriginChanged) + .size(15), + text(crate::t!("Place the UCS icon at the origin (UCSICON ORigin)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ]; + + if drawing_prefs.available { + modeling = modeling + .push(Space::new().height(24)) + .push( + row![ + text(crate::t!("Display Resolution")).size(15), + Space::new().width(10), + text(crate::t!("applies to the current drawing")).size(11), + ] + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Isolines per surface")).size(12).width(150), + slider( + 0..=64, + drawing_prefs.isolines.clamp(0, 64), + Message::IsolinesChanged, + ) + .step(1i16) + .width(Fill), + text(drawing_prefs.isolines.clamp(0, 64).to_string()) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + iced::widget::checkbox(drawing_prefs.display_silhouette) + .on_toggle(Message::DispSilhChanged) + .size(15), + text(crate::t!("Show silhouette edges on solids (DISPSILH)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Surface density U")).size(12).width(150), + slider( + 0..=200, + drawing_prefs.surface_u.clamp(0, 200), + Message::SurfaceUChanged, + ) + .step(1i16) + .width(Fill), + text(drawing_prefs.surface_u.clamp(0, 200).to_string()) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Surface density V")).size(12).width(150), + slider( + 0..=200, + drawing_prefs.surface_v.clamp(0, 200), + Message::SurfaceVChanged, + ) + .step(1i16) + .width(Fill), + text(drawing_prefs.surface_v.clamp(0, 200).to_string()) + .size(11) + .width(44), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Surface type")).size(12).width(150), + iced::widget::pick_list( + selected_surface_type, + surface_type_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::SurfaceTypeChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + ) + .push(Space::new().height(24)) + .push( + row![ + text(crate::t!("Solid History")).size(15), + Space::new().width(10), + text(crate::t!("applies to the current drawing")).size(11), + ] + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + iced::widget::checkbox(drawing_prefs.record_solid_history) + .on_toggle(Message::SolidHistChanged) + .size(15), + text(crate::t!("Record the history of composite solids (SOLIDHIST)")) + .size(12), + ] + .spacing(8) + .align_y(iced::Center), + ) + .push(Space::new().height(10)) + .push( + row![ + text(crate::t!("Show solid history")).size(12).width(150), + iced::widget::pick_list( + selected_show_history, + show_history_options, + |choice| choice.label.clone(), + ) + .on_select(|choice| Message::ShowHistChanged(choice.value)) + .width(Fill), + ] + .spacing(10) + .align_y(iced::Center), + ); + } + + let modeling = modeling.spacing(0).width(sizing.width); + let content: Element<'a, Message> = match active_tab { OptionsTab::General => general.into(), OptionsTab::Display => display_element.into(), OptionsTab::Selection => selection.into(), OptionsTab::OpenAndSave => open_and_save.into(), OptionsTab::Drafting => drafting.into(), + OptionsTab::Modeling => modeling.into(), OptionsTab::UserPreferences => user_prefs.into(), OptionsTab::Drawing => drawing.into(), }; @@ -1251,6 +1491,7 @@ pub fn view_window<'a>( tab_button(crate::t!("Open and Save"), OptionsTab::OpenAndSave), tab_button(crate::t!("Display"), OptionsTab::Display), tab_button(crate::t!("Drafting"), OptionsTab::Drafting), + tab_button(crate::t!("3D Modeling"), OptionsTab::Modeling), tab_button(crate::t!("Selection"), OptionsTab::Selection), tab_button(crate::t!("User Preferences"), OptionsTab::UserPreferences), tab_button(crate::t!("Drawing"), OptionsTab::Drawing), From 7ea1aef95e890361b593bc2829d11d5ea94e0985 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:45:20 +0300 Subject: [PATCH 10/53] Add a Files page, and links out to the editors that own their own dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The application has no support-file search path — the four folders it uses are functions returning fixed paths, not settings. Rather than build a Files page that pretends otherwise, this one answers the question people actually open it to ask: where does this thing keep my plot styles. Each row shows the location and opens it in the file manager, and a row whose path the platform could not give says so with its button disabled. General gains buttons through to the plugin manager, the keyboard shortcut editor and the alias editor. Those three have good dialogs of their own and are not absorbed; Options simply stops being a place you cannot reach them from. Co-Authored-By: Claude Opus 5 --- src/app/mod.rs | 2 + src/app/update/mod.rs | 2 + src/app/view/modal.rs | 11 +++++ src/ui/window/options.rs | 94 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 109 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 6ee56bbe4..9269c3b3d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2044,6 +2044,8 @@ pub enum Message { UcsIconAtOriginChanged(bool), /// Toggle selection cycling from Options; the status-bar pill toggles the same flag. SelectionCyclingChanged(bool), + /// Reveal one of the application's own folders in the system file manager. + OpenFolder(String), /// Change isolines per surface in the current drawing (ISOLINES). IsolinesChanged(i16), /// Toggle silhouette edges in the current drawing (DISPSILH). diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 3e88cb454..3b4844fe5 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6363,6 +6363,8 @@ impl OpenCADStudio { Task::none() } + Message::OpenFolder(path) => crate::sys::open_url(&path, None), + Message::PickDragRectToggled(rectangle) => { self.pick_drag_rect = rectangle; self.persist_settings_if_changed(); diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index e9b5278f6..0f59969ed 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -230,6 +230,17 @@ impl OpenCADStudio { .map_or(1, |h| h.show_solid_history), } }, + crate::ui::window::options::Folders { + config: crate::config::config_dir() + .map(|p| p.display().to_string()), + plot_styles: crate::io::plot_style::plot_styles_dir() + .ok() + .map(|p| p.display().to_string()), + plugins: crate::plugin::external::plugins_dir() + .map(|p| p.display().to_string()), + autosave: crate::config::config_dir() + .map(|_| std::env::temp_dir().display().to_string()), + }, self.double_click_block_refedit, self.double_click_block_attedit, self.cursor_type, diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index a367f24be..0923ebe21 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -25,6 +25,7 @@ const DIALOG_HEIGHT: f32 = 620.0; pub enum OptionsTab { #[default] General, + Files, OpenAndSave, Display, Drafting, @@ -74,6 +75,18 @@ pub struct AppPrefs { pub ucs_icon_at_origin: bool, } +/// The fixed locations the Files page lists. +/// +/// `None` where the platform could not give one — the row then says so and +/// its button is disabled rather than opening nothing. +#[derive(Clone, Default)] +pub struct Folders { + pub config: Option, + pub plot_styles: Option, + pub plugins: Option, + pub autosave: Option, +} + /// Values read from the current drawing's header rather than from preferences. /// /// These are saved in the DWG, so they follow the drawing rather than the @@ -142,6 +155,7 @@ pub fn view_window<'a>( prefs: AppPrefs, snap_angle_input: &'a str, drawing_prefs: DrawingPrefs, + folders: Folders, double_click_block_refedit: bool, double_click_block_attedit: bool, cursor_type: CursorType, @@ -259,6 +273,38 @@ pub fn view_window<'a>( ] .spacing(12) .align_y(iced::Center), + Space::new().height(24), + text(crate::t!("Applications")).size(15), + Space::new().height(10), + row![ + text(crate::t!("Installed plugins and their sources")).size(12).width(Fill), + button(text(crate::t!("Plugins…")).size(11)) + .on_press(Message::PluginManagerOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Keyboard shortcuts")).size(12).width(Fill), + button(text(crate::t!("Keyboard Shortcuts…")).size(11)) + .on_press(Message::ShortcutsPanelOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), + Space::new().height(10), + row![ + text(crate::t!("Command aliases")).size(12).width(Fill), + button(text(crate::t!("Command Aliases…")).size(11)) + .on_press(Message::AliasEditorOpen) + .padding([4, 10]) + .style(button::secondary), + ] + .spacing(10) + .align_y(iced::Center), ] .spacing(0) .width(sizing.width); @@ -1464,10 +1510,57 @@ pub fn view_window<'a>( let modeling = modeling.spacing(0).width(sizing.width); + + // The application has no support-file search path, so this page does not + // pretend to offer one. It shows where things actually live and opens the + // folder — which is the question people are really asking when they go + // looking for a Files page. + let folder_row = |label: std::borrow::Cow<'a, str>, path: Option| { + let shown = path.clone().unwrap_or_else(|| crate::t!("Not available").into_owned()); + let mut open = button(text(crate::t!("Open folder")).size(11)) + .padding([4, 10]) + .style(button::secondary); + if let Some(path) = path { + open = open.on_press(Message::OpenFolder(path)); + } + row![ + column![ + text(label).size(12), + text(shown).size(11), + ] + .spacing(2) + .width(Fill), + open, + ] + .spacing(10) + .align_y(iced::Center) + }; + + let files = column![ + text(crate::t!("Files")).size(15), + Space::new().height(6), + text(crate::t!( + "Where the application keeps its own files. These locations are fixed." + )) + .size(11) + .width(sizing.width), + Space::new().height(16), + folder_row(crate::t!("Configuration"), folders.config.clone()), + Space::new().height(12), + folder_row(crate::t!("Plot styles"), folders.plot_styles.clone()), + Space::new().height(12), + folder_row(crate::t!("Plugins"), folders.plugins.clone()), + Space::new().height(12), + folder_row(crate::t!("Autosave files"), folders.autosave.clone()), + ] + .spacing(0) + .width(sizing.width); + let content: Element<'a, Message> = match active_tab { OptionsTab::General => general.into(), OptionsTab::Display => display_element.into(), OptionsTab::Selection => selection.into(), + OptionsTab::Files => files.into(), OptionsTab::OpenAndSave => open_and_save.into(), OptionsTab::Drafting => drafting.into(), OptionsTab::Modeling => modeling.into(), @@ -1488,6 +1581,7 @@ pub fn view_window<'a>( }; let tabs = column![ tab_button(crate::t!("General"), OptionsTab::General), + tab_button(crate::t!("Files"), OptionsTab::Files), tab_button(crate::t!("Open and Save"), OptionsTab::OpenAndSave), tab_button(crate::t!("Display"), OptionsTab::Display), tab_button(crate::t!("Drafting"), OptionsTab::Drafting), From 55b2dc17965aff4d11e715526281aff73514dd82 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:55:34 +0300 Subject: [PATCH 11/53] Translate the Options dialog into all 21 languages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixty-five new strings, each one added to the source catalogue and given a value in every locale — not English placeholders. Terminology follows what each locale already uses for the surrounding labels rather than being coined fresh: grips, selection, preview, viewport, annotation scale and the UCS all have established words in these files, and the new strings use them. System-variable names stay in Latin uppercase inside the parentheses, as they already do in every locale's SETVAR listing — they are identifiers, not prose. One trap worth recording: Fluent attribute names must begin with a letter, so the obvious slug for "3D Modeling" is not a legal key. It parses as far as the message body and then fails the whole file, which surfaces as `Invalid Fluent syntax in EnUs` with a byte offset rather than as anything naming the attribute. Co-Authored-By: Claude Opus 5 --- locales/ar-SA/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/bg-BG/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/cs-CZ/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/de-DE/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/el-GR/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/en-US/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/es-ES/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/fi-FI/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/fr-FR/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/hi-IN/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/hu-HU/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/it-IT/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/ja-JP/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/ko-KR/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/nl-NL/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/pl-PL/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/pt-BR/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/ru-RU/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/tr-TR/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/zh-CN/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ locales/zh-TW/opencadstudio.ftl | 65 +++++++++++++++++++++++++++++++++ src/locale_catalog.rs | 65 +++++++++++++++++++++++++++++++++ 22 files changed, 1430 insertions(+) diff --git a/locales/ar-SA/opencadstudio.ftl b/locales/ar-SA/opencadstudio.ftl index fe5d8961f..6dd0fd5c2 100644 --- a/locales/ar-SA/opencadstudio.ftl +++ b/locales/ar-SA/opencadstudio.ftl @@ -1388,6 +1388,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = يتحكم في مربع التحديد المرئي ومنطقة النقر معًا. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = التطبيقات + .installed-plugins-and-their-sources = الإضافات المثبتة ومصادرها + .plugins-2 = الإضافات… + .keyboard-shortcuts-2 = اختصارات لوحة المفاتيح + .keyboard-shortcuts-3 = اختصارات لوحة المفاتيح… + .command-aliases-2 = الأسماء المستعارة للأوامر + .command-aliases-3 = الأسماء المستعارة للأوامر… + .file-safety = أمان الملفات + .automatic-save = الحفظ التلقائي + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = الدقائق بين عمليات حفظ الاسترداد في ملف ‎.sv$‎؛ القيمة 0 تعطّله (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = الاحتفاظ بنسخة ‎.bak‎ عند استبدال رسم (ISAVEBAK) + .plotting = الطباعة + .plot-device-paper-scale-and-plot-styles = جهاز الطباعة والورق والمقياس وأنماط الطباعة + .plot-and-page-setup = الطباعة وإعداد الصفحة… + .fill-truetype-glyphs-textfill = ملء محارف TrueType (TEXTFILL) + .command-line = سطر الأوامر + .prompt-lines = أسطر المطالبة + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = أسطر المطالبة المؤقتة أعلى نافذة الأوامر (CLIPROMPTLINES). + .history-fade-time = زمن تلاشي السجل + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = مدة بقاء أسطر السجل ظاهرة؛ القيمة 0 تتخطاها (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = النقر على الكائنات المتداخلة يفتح أداة اختيار + .block-edit = تحرير الكتلة + .double-click-to-edit-block-in-place-refedit = النقر المزدوج يحرر الكتلة في مكانها (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = النقر المزدوج على الكتل ذات السمات يحرر سماتها (ATTEDIT) + .user-preferences = تفضيلات المستخدم + .zoom = التكبير + .reverse-mouse-wheel-zoom-zoomwheel = عكس التكبير بعجلة الفأرة (ZOOMWHEEL) + .zoom-factor = معامل التكبير + .how-far-one-wheel-notch-zooms-zoomfactor = مقدار التكبير لكل درجة من العجلة (ZOOMFACTOR). + .text-and-dimensions = النص والأبعاد + .textedit-edits-one-object-and-ends-texteditmode = يحرر TEXTEDIT كائنًا واحدًا ثم ينتهي (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = الأبعاد المتصلة ترث طبقة ونمط البعد الأساسي (DIMCONTINUEMODE) + .qdim-origin-priority = أولوية أصل QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = النقاط التي يقيس منها QDIM. يمكن ضبطها داخل الأمر أيضًا. + .add-scales-automatically = إضافة المقاييس تلقائيًا + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = الكائنات التوضيحية التي تتبنى مقياس توضيح تم ضبطه حديثًا (ANNOAUTOSCALE). + .length-angle-and-insertion-units = وحدات الطول والزاوية والإدراج + .drawing-units-2 = وحدات الرسم… + .drafting = الرسم + .drafting-rotation = دوران الرسم + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = يدير الشعيرات المتقاطعة وشبكة الالتقاط في نظام الإحداثيات النشط (SNAPANG). + .polar-tracking-increment = زيادة التتبع القطبي + .snap-and-grid = الالتقاط والشبكة + .snap-modes-grid-spacing-and-object-snap = أوضاع الالتقاط وتباعد الشبكة والتقاط الكائنات + .drafting-settings-2 = إعدادات الرسم… + .modeling-3d = النمذجة ثلاثية الأبعاد + .display-tools = أدوات العرض + .show-the-navigation-cube-navvcube = إظهار مكعب التنقل (NAVVCUBE) + .show-the-ucs-icon-ucsicon = إظهار أيقونة نظام الإحداثيات (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = وضع أيقونة نظام الإحداثيات عند نقطة الأصل (UCSICON ORigin) + .display-resolution = دقة العرض + .applies-to-the-current-drawing = ينطبق على الرسم الحالي + .isolines-per-surface = خطوط التساوي لكل سطح + .show-silhouette-edges-on-solids-dispsilh = إظهار حواف الظل الخارجي على المجسمات (DISPSILH) + .surface-density-u = كثافة السطح U + .surface-density-v = كثافة السطح V + .surface-type = نوع السطح + .record-the-history-of-composite-solids-solidhist = تسجيل تاريخ المجسمات المركبة (SOLIDHIST) + .show-solid-history = إظهار تاريخ المجسمات + .not-available = غير متاح + .open-folder = فتح المجلد + .where-the-application-keeps-its-own-files-these-locations-are-fixed = المكان الذي يحفظ فيه التطبيق ملفاته الخاصة. هذه المواقع ثابتة. + .configuration = التهيئة + .plot-styles = أنماط الطباعة + .autosave-files = ملفات الحفظ التلقائي .selection-modes = أوضاع التحديد .use-shift-to-add-to-selection-pickadd = استخدام Shift للإضافة إلى التحديد (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = الضغط والسحب يرسم مستطيلاً بدلاً من التحديد الحر (PICKDRAG) diff --git a/locales/bg-BG/opencadstudio.ftl b/locales/bg-BG/opencadstudio.ftl index 01b8c9683..cb93e9870 100644 --- a/locales/bg-BG/opencadstudio.ftl +++ b/locales/bg-BG/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Управлява видимото поле за избор и областта за щракване. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Приложения + .installed-plugins-and-their-sources = Инсталирани добавки и техните източници + .plugins-2 = Добавки… + .keyboard-shortcuts-2 = Клавишни комбинации + .keyboard-shortcuts-3 = Клавишни комбинации… + .command-aliases-2 = Псевдоними на команди + .command-aliases-3 = Псевдоними на команди… + .file-safety = Безопасност на файловете + .automatic-save = Автоматично записване + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Минути между възстановителните записи във файл .sv$; 0 го изключва (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Запазване на копие .bak при презаписване на чертеж (ISAVEBAK) + .plotting = Чертане + .plot-device-paper-scale-and-plot-styles = Устройство за чертане, хартия, мащаб и стилове на чертане + .plot-and-page-setup = Чертане и настройка на страница… + .fill-truetype-glyphs-textfill = Запълване на глифове TrueType (TEXTFILL) + .command-line = Команден ред + .prompt-lines = Редове с подкана + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Временни редове с подкана над командния прозорец (CLIPROMPTLINES). + .history-fade-time = Време на избледняване на историята + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Колко дълго остават видими редовете от историята; 0 ги пропуска (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Щракването върху припокриващи се обекти отваря избирач + .block-edit = Редактиране на блок + .double-click-to-edit-block-in-place-refedit = Двойно щракване редактира блока на място (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Двойно щракване върху блокове с атрибути редактира атрибутите (ATTEDIT) + .user-preferences = Потребителски предпочитания + .zoom = Мащабиране + .reverse-mouse-wheel-zoom-zoomwheel = Обръщане на мащабирането с колелцето (ZOOMWHEEL) + .zoom-factor = Коефициент на мащабиране + .how-far-one-wheel-notch-zooms-zoomfactor = Колко мащабира едно щракване на колелцето (ZOOMFACTOR). + .text-and-dimensions = Текст и размери + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT редактира един обект и приключва (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Продължените размери наследяват слоя и стила на базовия размер (DIMCONTINUEMODE) + .qdim-origin-priority = Приоритет на началото на QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = От кои точки мери QDIM. Настройва се и в самата команда. + .add-scales-automatically = Автоматично добавяне на мащаби + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Кои анотативни обекти приемат новозададен мащаб на анотация (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Единици за дължина, ъгъл и вмъкване + .drawing-units-2 = Единици на чертежа… + .drafting = Чертане на ръка + .drafting-rotation = Завъртане на чертането + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Завърта мерника и мрежата за прилепване в активната ПКС (SNAPANG). + .polar-tracking-increment = Стъпка на полярно проследяване + .snap-and-grid = Прилепване и мрежа + .snap-modes-grid-spacing-and-object-snap = Режими на прилепване, стъпка на мрежата и прилепване към обекти + .drafting-settings-2 = Настройки на чертането… + .modeling-3d = 3D моделиране + .display-tools = Инструменти за изглед + .show-the-navigation-cube-navvcube = Показвай навигационния куб (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Показвай иконата на ПКС (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Поставяй иконата на ПКС в началото (UCSICON ORigin) + .display-resolution = Разделителна способност на изгледа + .applies-to-the-current-drawing = отнася се за текущия чертеж + .isolines-per-surface = Изолинии на повърхнина + .show-silhouette-edges-on-solids-dispsilh = Показвай силуетни ръбове на телата (DISPSILH) + .surface-density-u = Плътност на повърхнината U + .surface-density-v = Плътност на повърхнината V + .surface-type = Тип повърхнина + .record-the-history-of-composite-solids-solidhist = Записвай историята на съставните тела (SOLIDHIST) + .show-solid-history = Показвай историята на телата + .not-available = Не е налично + .open-folder = Отвори папката + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Къде приложението държи собствените си файлове. Тези места са фиксирани. + .configuration = Конфигурация + .plot-styles = Стилове на чертане + .autosave-files = Файлове за автоматично записване .selection-modes = Режими на избор .use-shift-to-add-to-selection-pickadd = Използвай Shift за добавяне към избора (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Натискане и влачене чертае правоъгълник вместо ласо (PICKDRAG) diff --git a/locales/cs-CZ/opencadstudio.ftl b/locales/cs-CZ/opencadstudio.ftl index 1c4332caa..16f1737ec 100644 --- a/locales/cs-CZ/opencadstudio.ftl +++ b/locales/cs-CZ/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Ovládá viditelný výběrový rámeček i oblast kliknutí. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Aplikace + .installed-plugins-and-their-sources = Nainstalované zásuvné moduly a jejich zdroje + .plugins-2 = Zásuvné moduly… + .keyboard-shortcuts-2 = Klávesové zkratky + .keyboard-shortcuts-3 = Klávesové zkratky… + .command-aliases-2 = Aliasy příkazů + .command-aliases-3 = Aliasy příkazů… + .file-safety = Bezpečnost souborů + .automatic-save = Automatické ukládání + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuty mezi obnovovacími uloženími do souboru .sv$; 0 je vypne (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Při přepsání výkresu ponechat kopii .bak (ISAVEBAK) + .plotting = Vykreslování + .plot-device-paper-scale-and-plot-styles = Vykreslovací zařízení, papír, měřítko a styly vykreslení + .plot-and-page-setup = Vykreslit a nastavení stránky… + .fill-truetype-glyphs-textfill = Vyplňovat glyfy TrueType (TEXTFILL) + .command-line = Příkazový řádek + .prompt-lines = Řádky výzvy + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Dočasné řádky výzvy nad příkazovým oknem (CLIPROMPTLINES). + .history-fade-time = Doba mizení historie + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Jak dlouho zůstávají řádky historie viditelné; 0 je přeskočí (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Kliknutí na překrývající se objekty otevře výběr + .block-edit = Úprava bloku + .double-click-to-edit-block-in-place-refedit = Dvojklik upraví blok na místě (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Dvojklik na bloky s atributy upraví atributy (ATTEDIT) + .user-preferences = Předvolby uživatele + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Obrátit zoom kolečkem myši (ZOOMWHEEL) + .zoom-factor = Faktor zoomu + .how-far-one-wheel-notch-zooms-zoomfactor = O kolik přiblíží jeden zub kolečka (ZOOMFACTOR). + .text-and-dimensions = Text a kóty + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT upraví jeden objekt a skončí (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Navazující kóty dědí hladinu a styl základní kóty (DIMCONTINUEMODE) + .qdim-origin-priority = Priorita počátku QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Ze kterých bodů QDIM měří. Nastavitelné i uvnitř příkazu. + .add-scales-automatically = Přidávat měřítka automaticky + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Které anotativní objekty převezmou nově nastavené měřítko anotace (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Jednotky délky, úhlu a vložení + .drawing-units-2 = Jednotky výkresu… + .drafting = Kreslení + .drafting-rotation = Otočení kreslení + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Otáčí nitkový kříž a rastr uchopení v aktivním USS (SNAPANG). + .polar-tracking-increment = Krok polárního sledování + .snap-and-grid = Uchopení a rastr + .snap-modes-grid-spacing-and-object-snap = Režimy uchopení, rozteč rastru a uchopení objektů + .drafting-settings-2 = Nastavení kreslení… + .modeling-3d = 3D modelování + .display-tools = Nástroje zobrazení + .show-the-navigation-cube-navvcube = Zobrazit navigační kostku (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Zobrazit ikonu USS (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Umístit ikonu USS do počátku (UCSICON ORigin) + .display-resolution = Rozlišení zobrazení + .applies-to-the-current-drawing = platí pro aktuální výkres + .isolines-per-surface = Izočáry na plochu + .show-silhouette-edges-on-solids-dispsilh = Zobrazit obrysové hrany těles (DISPSILH) + .surface-density-u = Hustota plochy U + .surface-density-v = Hustota plochy V + .surface-type = Typ plochy + .record-the-history-of-composite-solids-solidhist = Zaznamenávat historii složených těles (SOLIDHIST) + .show-solid-history = Zobrazit historii těles + .not-available = Není k dispozici + .open-folder = Otevřít složku + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Kde aplikace uchovává vlastní soubory. Tato umístění jsou pevná. + .configuration = Konfigurace + .plot-styles = Styly vykreslení + .autosave-files = Soubory automatického ukládání .selection-modes = Režimy výběru .use-shift-to-add-to-selection-pickadd = Použít Shift pro přidání do výběru (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Tažení kreslí obdélník místo lasa (PICKDRAG) diff --git a/locales/de-DE/opencadstudio.ftl b/locales/de-DE/opencadstudio.ftl index 42781eee3..6560a0558 100644 --- a/locales/de-DE/opencadstudio.ftl +++ b/locales/de-DE/opencadstudio.ftl @@ -1376,6 +1376,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Steuert sowohl das sichtbare Auswahlkästchen als auch den Klickbereich. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Anwendungen + .installed-plugins-and-their-sources = Installierte Plug-ins und ihre Quellen + .plugins-2 = Plug-ins… + .keyboard-shortcuts-2 = Tastenkürzel + .keyboard-shortcuts-3 = Tastenkürzel… + .command-aliases-2 = Befehlsaliase + .command-aliases-3 = Befehlsaliase… + .file-safety = Dateisicherheit + .automatic-save = Automatisches Speichern + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuten zwischen Wiederherstellungsspeicherungen in eine .sv$-Datei; 0 schaltet es aus (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Beim Überschreiben eine .bak-Kopie behalten (ISAVEBAK) + .plotting = Plotten + .plot-device-paper-scale-and-plot-styles = Plotgerät, Papier, Maßstab und Plotstile + .plot-and-page-setup = Plotten und Seiteneinrichtung… + .fill-truetype-glyphs-textfill = TrueType-Glyphen füllen (TEXTFILL) + .command-line = Befehlszeile + .prompt-lines = Eingabeaufforderungszeilen + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Temporäre Eingabeaufforderungszeilen über dem Befehlsfenster (CLIPROMPTLINES). + .history-fade-time = Ausblendzeit des Verlaufs + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Wie lange Verlaufszeilen sichtbar bleiben; 0 überspringt sie (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Klick auf überlappende Objekte öffnet eine Auswahl + .block-edit = Blockbearbeitung + .double-click-to-edit-block-in-place-refedit = Doppelklick bearbeitet den Block an Ort und Stelle (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Doppelklick auf Blöcke mit Attributen bearbeitet diese (ATTEDIT) + .user-preferences = Benutzereinstellungen + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Mausradzoom umkehren (ZOOMWHEEL) + .zoom-factor = Zoomfaktor + .how-far-one-wheel-notch-zooms-zoomfactor = Wie weit eine Radrastung zoomt (ZOOMFACTOR). + .text-and-dimensions = Text und Bemaßung + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT bearbeitet ein Objekt und endet (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Fortgesetzte Bemaßungen erben Layer und Stil der Basisbemaßung (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM-Ursprungspriorität + .which-points-qdim-measures-from-also-settable-inside-the-command = Von welchen Punkten QDIM misst. Auch im Befehl einstellbar. + .add-scales-automatically = Maßstäbe automatisch hinzufügen + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Welche annotativen Objekte einen neu gesetzten Beschriftungsmaßstab übernehmen (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Längen-, Winkel- und Einfügeeinheiten + .drawing-units-2 = Zeichnungseinheiten… + .drafting = Entwurf + .drafting-rotation = Entwurfsdrehung + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Dreht Fadenkreuz und Fangraster im aktiven BKS (SNAPANG). + .polar-tracking-increment = Polarer Verfolgungsschritt + .snap-and-grid = Fang und Raster + .snap-modes-grid-spacing-and-object-snap = Fangmodi, Rasterabstand und Objektfang + .drafting-settings-2 = Entwurfseinstellungen… + .modeling-3d = 3D-Modellierung + .display-tools = Anzeigewerkzeuge + .show-the-navigation-cube-navvcube = Navigationswürfel anzeigen (NAVVCUBE) + .show-the-ucs-icon-ucsicon = BKS-Symbol anzeigen (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = BKS-Symbol am Ursprung platzieren (UCSICON ORigin) + .display-resolution = Anzeigeauflösung + .applies-to-the-current-drawing = gilt für die aktuelle Zeichnung + .isolines-per-surface = Isolinien pro Fläche + .show-silhouette-edges-on-solids-dispsilh = Silhouettenkanten an Volumenkörpern anzeigen (DISPSILH) + .surface-density-u = Flächendichte U + .surface-density-v = Flächendichte V + .surface-type = Flächentyp + .record-the-history-of-composite-solids-solidhist = Verlauf zusammengesetzter Volumenkörper aufzeichnen (SOLIDHIST) + .show-solid-history = Volumenkörperverlauf anzeigen + .not-available = Nicht verfügbar + .open-folder = Ordner öffnen + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Wo die Anwendung ihre eigenen Dateien ablegt. Diese Orte sind fest. + .configuration = Konfiguration + .plot-styles = Plotstile + .autosave-files = Dateien für automatisches Speichern .selection-modes = Auswahlmodi .use-shift-to-add-to-selection-pickadd = Umschalt zum Hinzufügen zur Auswahl verwenden (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Ziehen mit gedrückter Maustaste zeichnet ein Rechteck statt eines Lassos (PICKDRAG) diff --git a/locales/el-GR/opencadstudio.ftl b/locales/el-GR/opencadstudio.ftl index 96d170fd6..4adf40ce0 100644 --- a/locales/el-GR/opencadstudio.ftl +++ b/locales/el-GR/opencadstudio.ftl @@ -1377,6 +1377,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Ελέγχει τόσο το ορατό πλαίσιο επιλογής όσο και την περιοχή κλικ. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Εφαρμογές + .installed-plugins-and-their-sources = Εγκατεστημένα πρόσθετα και οι πηγές τους + .plugins-2 = Πρόσθετα… + .keyboard-shortcuts-2 = Συντομεύσεις πληκτρολογίου + .keyboard-shortcuts-3 = Συντομεύσεις πληκτρολογίου… + .command-aliases-2 = Ψευδώνυμα εντολών + .command-aliases-3 = Ψευδώνυμα εντολών… + .file-safety = Ασφάλεια αρχείων + .automatic-save = Αυτόματη αποθήκευση + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Λεπτά μεταξύ αποθηκεύσεων ανάκτησης σε αρχείο .sv$· το 0 την απενεργοποιεί (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Διατήρηση αντιγράφου .bak κατά την αντικατάσταση σχεδίου (ISAVEBAK) + .plotting = Εκτύπωση + .plot-device-paper-scale-and-plot-styles = Συσκευή εκτύπωσης, χαρτί, κλίμακα και στυλ εκτύπωσης + .plot-and-page-setup = Εκτύπωση και διαμόρφωση σελίδας… + .fill-truetype-glyphs-textfill = Γέμισμα γραμματοσειρών TrueType (TEXTFILL) + .command-line = Γραμμή εντολών + .prompt-lines = Γραμμές προτροπής + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Προσωρινές γραμμές προτροπής πάνω από το παράθυρο εντολών (CLIPROMPTLINES). + .history-fade-time = Χρόνος εξασθένισης ιστορικού + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Πόσο μένουν ορατές οι γραμμές ιστορικού· το 0 τις παραλείπει (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Το κλικ σε επικαλυπτόμενα αντικείμενα ανοίγει επιλογέα + .block-edit = Επεξεργασία μπλοκ + .double-click-to-edit-block-in-place-refedit = Διπλό κλικ για επεξεργασία μπλοκ επί τόπου (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Διπλό κλικ σε μπλοκ με χαρακτηριστικά για επεξεργασία τους (ATTEDIT) + .user-preferences = Προτιμήσεις χρήστη + .zoom = Ζουμ + .reverse-mouse-wheel-zoom-zoomwheel = Αντιστροφή ζουμ με τον τροχό (ZOOMWHEEL) + .zoom-factor = Συντελεστής ζουμ + .how-far-one-wheel-notch-zooms-zoomfactor = Πόσο ζουμάρει ένα σκαλί του τροχού (ZOOMFACTOR). + .text-and-dimensions = Κείμενο και διαστάσεις + .textedit-edits-one-object-and-ends-texteditmode = Το TEXTEDIT επεξεργάζεται ένα αντικείμενο και τερματίζει (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Οι συνεχόμενες διαστάσεις κληρονομούν επίπεδο και στυλ από τη βασική (DIMCONTINUEMODE) + .qdim-origin-priority = Προτεραιότητα αρχής QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Από ποια σημεία μετρά το QDIM. Ρυθμίζεται και μέσα στην εντολή. + .add-scales-automatically = Αυτόματη προσθήκη κλιμάκων + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Ποια επισημειωματικά αντικείμενα παίρνουν μια νέα κλίμακα επισημείωσης (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Μονάδες μήκους, γωνίας και εισαγωγής + .drawing-units-2 = Μονάδες σχεδίου… + .drafting = Σχεδίαση + .drafting-rotation = Περιστροφή σχεδίασης + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Περιστρέφει το σταυρόνημα και το πλέγμα έλξης στο ενεργό UCS (SNAPANG). + .polar-tracking-increment = Βήμα πολικής παρακολούθησης + .snap-and-grid = Έλξη και πλέγμα + .snap-modes-grid-spacing-and-object-snap = Λειτουργίες έλξης, βήμα πλέγματος και έλξη αντικειμένων + .drafting-settings-2 = Ρυθμίσεις σχεδίασης… + .modeling-3d = Μοντελοποίηση 3D + .display-tools = Εργαλεία προβολής + .show-the-navigation-cube-navvcube = Εμφάνιση του κύβου πλοήγησης (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Εμφάνιση του εικονιδίου UCS (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Τοποθέτηση του εικονιδίου UCS στην αρχή των αξόνων (UCSICON ORigin) + .display-resolution = Ανάλυση εμφάνισης + .applies-to-the-current-drawing = ισχύει για το τρέχον σχέδιο + .isolines-per-surface = Ισοϋψείς ανά επιφάνεια + .show-silhouette-edges-on-solids-dispsilh = Εμφάνιση ακμών περιγράμματος σε στερεά (DISPSILH) + .surface-density-u = Πυκνότητα επιφάνειας U + .surface-density-v = Πυκνότητα επιφάνειας V + .surface-type = Τύπος επιφάνειας + .record-the-history-of-composite-solids-solidhist = Καταγραφή ιστορικού σύνθετων στερεών (SOLIDHIST) + .show-solid-history = Εμφάνιση ιστορικού στερεών + .not-available = Μη διαθέσιμο + .open-folder = Άνοιγμα φακέλου + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Πού κρατά η εφαρμογή τα δικά της αρχεία. Οι θέσεις αυτές είναι σταθερές. + .configuration = Ρυθμίσεις + .plot-styles = Στυλ εκτύπωσης + .autosave-files = Αρχεία αυτόματης αποθήκευσης .selection-modes = Τρόποι επιλογής .use-shift-to-add-to-selection-pickadd = Χρήση Shift για προσθήκη στην επιλογή (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Το πάτημα-και-σύρσιμο σχεδιάζει ορθογώνιο αντί για λάσο (PICKDRAG) diff --git a/locales/en-US/opencadstudio.ftl b/locales/en-US/opencadstudio.ftl index 0a4d99304..01541f940 100644 --- a/locales/en-US/opencadstudio.ftl +++ b/locales/en-US/opencadstudio.ftl @@ -1377,6 +1377,71 @@ common = .pick-box-size = Pick box size .controls-both-the-visible-selection-box-and-the-click-aperture = Controls both the visible selection box and the click aperture. .visual-effect-settings = Visual Effect Settings + .applications = Applications + .installed-plugins-and-their-sources = Installed plugins and their sources + .plugins-2 = Plugins… + .keyboard-shortcuts-2 = Keyboard shortcuts + .keyboard-shortcuts-3 = Keyboard Shortcuts… + .command-aliases-2 = Command aliases + .command-aliases-3 = Command Aliases… + .file-safety = File Safety + .automatic-save = Automatic save + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minutes between recovery saves to a .sv$ file; 0 turns it off (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Keep a .bak copy when overwriting a drawing (ISAVEBAK) + .plotting = Plotting + .plot-device-paper-scale-and-plot-styles = Plot device, paper, scale and plot styles + .plot-and-page-setup = Plot and Page Setup… + .fill-truetype-glyphs-textfill = Fill TrueType glyphs (TEXTFILL) + .command-line = Command Line + .prompt-lines = Prompt lines + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Temporary prompt lines shown above the command window (CLIPROMPTLINES). + .history-fade-time = History fade time + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = How long overlay history lines stay visible; 0 skips them (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Clicking overlapping objects opens a picker + .block-edit = Block Edit + .double-click-to-edit-block-in-place-refedit = Double-click to edit block in-place (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Double-click attributed blocks to edit attributes (ATTEDIT) + .user-preferences = User Preferences + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Reverse mouse wheel zoom (ZOOMWHEEL) + .zoom-factor = Zoom factor + .how-far-one-wheel-notch-zooms-zoomfactor = How far one wheel notch zooms (ZOOMFACTOR). + .text-and-dimensions = Text and Dimensions + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT edits one object and ends (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Continued dimensions inherit the base dimension's layer and style (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM origin priority + .which-points-qdim-measures-from-also-settable-inside-the-command = Which points QDIM measures from. Also settable inside the command. + .add-scales-automatically = Add scales automatically + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Which annotative objects pick up a newly set annotation scale (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Length, angle and insertion units + .drawing-units-2 = Drawing Units… + .drafting = Drafting + .drafting-rotation = Drafting rotation + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Rotates the crosshair and the snap grid in the active UCS (SNAPANG). + .polar-tracking-increment = Polar tracking increment + .snap-and-grid = Snap and Grid + .snap-modes-grid-spacing-and-object-snap = Snap modes, grid spacing and object snap + .drafting-settings-2 = Drafting Settings… + .modeling-3d = 3D Modeling + .display-tools = Display Tools + .show-the-navigation-cube-navvcube = Show the navigation cube (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Show the UCS icon (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Place the UCS icon at the origin (UCSICON ORigin) + .display-resolution = Display Resolution + .applies-to-the-current-drawing = applies to the current drawing + .isolines-per-surface = Isolines per surface + .show-silhouette-edges-on-solids-dispsilh = Show silhouette edges on solids (DISPSILH) + .surface-density-u = Surface density U + .surface-density-v = Surface density V + .surface-type = Surface type + .record-the-history-of-composite-solids-solidhist = Record the history of composite solids (SOLIDHIST) + .show-solid-history = Show solid history + .not-available = Not available + .open-folder = Open folder + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Where the application keeps its own files. These locations are fixed. + .configuration = Configuration + .plot-styles = Plot styles + .autosave-files = Autosave files .selection-modes = Selection Modes .use-shift-to-add-to-selection-pickadd = Use Shift to add to selection (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Press and drag draws a rectangle instead of a lasso (PICKDRAG) diff --git a/locales/es-ES/opencadstudio.ftl b/locales/es-ES/opencadstudio.ftl index 6941fa1f2..c53101c47 100644 --- a/locales/es-ES/opencadstudio.ftl +++ b/locales/es-ES/opencadstudio.ftl @@ -1376,6 +1376,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controla tanto el cuadro de selección visible como el área de clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Aplicaciones + .installed-plugins-and-their-sources = Complementos instalados y sus orígenes + .plugins-2 = Complementos… + .keyboard-shortcuts-2 = Atajos de teclado + .keyboard-shortcuts-3 = Atajos de teclado… + .command-aliases-2 = Alias de comandos + .command-aliases-3 = Alias de comandos… + .file-safety = Seguridad de archivos + .automatic-save = Guardado automático + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minutos entre guardados de recuperación en un archivo .sv$; 0 lo desactiva (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Conservar una copia .bak al sobrescribir un dibujo (ISAVEBAK) + .plotting = Trazado + .plot-device-paper-scale-and-plot-styles = Dispositivo de trazado, papel, escala y estilos de trazado + .plot-and-page-setup = Trazar y configurar página… + .fill-truetype-glyphs-textfill = Rellenar glifos TrueType (TEXTFILL) + .command-line = Línea de comandos + .prompt-lines = Líneas de solicitud + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Líneas de solicitud temporales sobre la ventana de comandos (CLIPROMPTLINES). + .history-fade-time = Tiempo de desvanecimiento del historial + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Cuánto permanecen visibles las líneas del historial; 0 las omite (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Al pulsar sobre objetos superpuestos se abre un selector + .block-edit = Edición de bloque + .double-click-to-edit-block-in-place-refedit = Doble clic para editar el bloque in situ (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Doble clic en bloques con atributos para editarlos (ATTEDIT) + .user-preferences = Preferencias de usuario + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Invertir el zoom de la rueda del ratón (ZOOMWHEEL) + .zoom-factor = Factor de zoom + .how-far-one-wheel-notch-zooms-zoomfactor = Cuánto amplía un paso de la rueda (ZOOMFACTOR). + .text-and-dimensions = Texto y acotación + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT edita un objeto y termina (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Las cotas continuadas heredan la capa y el estilo de la cota base (DIMCONTINUEMODE) + .qdim-origin-priority = Prioridad de origen de QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Desde qué puntos mide QDIM. También se ajusta dentro del comando. + .add-scales-automatically = Añadir escalas automáticamente + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Qué objetos anotativos adoptan una escala de anotación recién definida (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Unidades de longitud, ángulo e inserción + .drawing-units-2 = Unidades del dibujo… + .drafting = Dibujo + .drafting-rotation = Rotación de dibujo + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Gira la mira y la rejilla de forzado en el SCP activo (SNAPANG). + .polar-tracking-increment = Incremento de rastreo polar + .snap-and-grid = Forzado y rejilla + .snap-modes-grid-spacing-and-object-snap = Modos de forzado, espaciado de rejilla y referencia a objetos + .drafting-settings-2 = Parámetros de dibujo… + .modeling-3d = Modelado 3D + .display-tools = Herramientas de visualización + .show-the-navigation-cube-navvcube = Mostrar el cubo de navegación (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Mostrar el icono SCP (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Situar el icono SCP en el origen (UCSICON ORigin) + .display-resolution = Resolución de visualización + .applies-to-the-current-drawing = se aplica al dibujo actual + .isolines-per-surface = Isolíneas por superficie + .show-silhouette-edges-on-solids-dispsilh = Mostrar aristas de silueta en sólidos (DISPSILH) + .surface-density-u = Densidad de superficie U + .surface-density-v = Densidad de superficie V + .surface-type = Tipo de superficie + .record-the-history-of-composite-solids-solidhist = Registrar el historial de sólidos compuestos (SOLIDHIST) + .show-solid-history = Mostrar el historial de sólidos + .not-available = No disponible + .open-folder = Abrir carpeta + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Dónde guarda la aplicación sus propios archivos. Estas ubicaciones son fijas. + .configuration = Configuración + .plot-styles = Estilos de trazado + .autosave-files = Archivos de guardado automático .selection-modes = Modos de selección .use-shift-to-add-to-selection-pickadd = Usar Mayús para añadir a la selección (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Arrastrar dibuja un rectángulo en lugar de un lazo (PICKDRAG) diff --git a/locales/fi-FI/opencadstudio.ftl b/locales/fi-FI/opencadstudio.ftl index f251e2663..cc2df4dd5 100644 --- a/locales/fi-FI/opencadstudio.ftl +++ b/locales/fi-FI/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Säätää sekä näkyvää valintaruutua että napsautusaluetta. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Sovellukset + .installed-plugins-and-their-sources = Asennetut laajennukset ja niiden lähteet + .plugins-2 = Laajennukset… + .keyboard-shortcuts-2 = Pikanäppäimet + .keyboard-shortcuts-3 = Pikanäppäimet… + .command-aliases-2 = Komentojen aliakset + .command-aliases-3 = Komentojen aliakset… + .file-safety = Tiedostoturvallisuus + .automatic-save = Automaattinen tallennus + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuutit palautustallennusten välillä .sv$-tiedostoon; 0 poistaa käytöstä (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Säilytä .bak-kopio piirustusta korvattaessa (ISAVEBAK) + .plotting = Tulostus + .plot-device-paper-scale-and-plot-styles = Tulostuslaite, paperi, mittakaava ja tulostustyylit + .plot-and-page-setup = Tulosta ja sivun asetukset… + .fill-truetype-glyphs-textfill = Täytä TrueType-merkit (TEXTFILL) + .command-line = Komentorivi + .prompt-lines = Kehoterivit + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Väliaikaiset kehoterivit komentoikkunan yläpuolella (CLIPROMPTLINES). + .history-fade-time = Historian häivytysaika + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Kuinka kauan historiarivit pysyvät näkyvissä; 0 ohittaa ne (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Päällekkäisten objektien napsauttaminen avaa valitsimen + .block-edit = Lohkon muokkaus + .double-click-to-edit-block-in-place-refedit = Kaksoisnapsautus muokkaa lohkoa paikallaan (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Kaksoisnapsautus määritteellisiin lohkoihin muokkaa määritteitä (ATTEDIT) + .user-preferences = Käyttäjäasetukset + .zoom = Zoomaus + .reverse-mouse-wheel-zoom-zoomwheel = Käännä hiiren rullan zoomaus (ZOOMWHEEL) + .zoom-factor = Zoomauskerroin + .how-far-one-wheel-notch-zooms-zoomfactor = Kuinka paljon yksi rullan pykälä zoomaa (ZOOMFACTOR). + .text-and-dimensions = Teksti ja mitoitus + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT muokkaa yhden objektin ja päättyy (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Jatketut mitat perivät perusmitan tason ja tyylin (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM-lähtöpisteen prioriteetti + .which-points-qdim-measures-from-also-settable-inside-the-command = Mistä pisteistä QDIM mittaa. Asetettavissa myös komennon sisällä. + .add-scales-automatically = Lisää mittakaavat automaattisesti + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Mitkä selittävät objektit omaksuvat juuri asetetun selitemittakaavan (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Pituus-, kulma- ja lisäysyksiköt + .drawing-units-2 = Piirustuksen yksiköt… + .drafting = Piirto + .drafting-rotation = Piirron kierto + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Kiertää hiusristikkoa ja tarttumaruudukkoa aktiivisessa UCS:ssä (SNAPANG). + .polar-tracking-increment = Polaarisen seurannan askel + .snap-and-grid = Tartunta ja ruudukko + .snap-modes-grid-spacing-and-object-snap = Tarttumatilat, ruudukkoväli ja objektitartunta + .drafting-settings-2 = Piirtoasetukset… + .modeling-3d = 3D-mallinnus + .display-tools = Näyttötyökalut + .show-the-navigation-cube-navvcube = Näytä navigointikuutio (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Näytä UCS-kuvake (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Sijoita UCS-kuvake origoon (UCSICON ORigin) + .display-resolution = Näytön tarkkuus + .applies-to-the-current-drawing = koskee nykyistä piirustusta + .isolines-per-surface = Isoviivoja pintaa kohti + .show-silhouette-edges-on-solids-dispsilh = Näytä siluettireunat kappaleissa (DISPSILH) + .surface-density-u = Pinnan tiheys U + .surface-density-v = Pinnan tiheys V + .surface-type = Pinnan tyyppi + .record-the-history-of-composite-solids-solidhist = Tallenna koostekappaleiden historia (SOLIDHIST) + .show-solid-history = Näytä kappalehistoria + .not-available = Ei käytettävissä + .open-folder = Avaa kansio + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Missä sovellus säilyttää omat tiedostonsa. Nämä sijainnit ovat kiinteät. + .configuration = Määritykset + .plot-styles = Tulostustyylit + .autosave-files = Automaattitallennuksen tiedostot .selection-modes = Valintatilat .use-shift-to-add-to-selection-pickadd = Käytä Vaihto-näppäintä valintaan lisäämiseen (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Painallus ja veto piirtää suorakulmion lasson sijaan (PICKDRAG) diff --git a/locales/fr-FR/opencadstudio.ftl b/locales/fr-FR/opencadstudio.ftl index b04978ac3..34b31d280 100644 --- a/locales/fr-FR/opencadstudio.ftl +++ b/locales/fr-FR/opencadstudio.ftl @@ -1376,6 +1376,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Contrôle à la fois la case de sélection visible et la zone de clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Applications + .installed-plugins-and-their-sources = Plug-ins installés et leurs sources + .plugins-2 = Plug-ins… + .keyboard-shortcuts-2 = Raccourcis clavier + .keyboard-shortcuts-3 = Raccourcis clavier… + .command-aliases-2 = Alias de commandes + .command-aliases-3 = Alias de commandes… + .file-safety = Sécurité des fichiers + .automatic-save = Enregistrement automatique + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minutes entre les enregistrements de récupération dans un fichier .sv$ ; 0 le désactive (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Conserver une copie .bak lors de l'écrasement d'un dessin (ISAVEBAK) + .plotting = Traçage + .plot-device-paper-scale-and-plot-styles = Périphérique de traçage, papier, échelle et styles de tracé + .plot-and-page-setup = Tracer et mise en page… + .fill-truetype-glyphs-textfill = Remplir les glyphes TrueType (TEXTFILL) + .command-line = Ligne de commande + .prompt-lines = Lignes d'invite + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Lignes d'invite temporaires au-dessus de la fenêtre de commande (CLIPROMPTLINES). + .history-fade-time = Durée d'estompage de l'historique + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Combien de temps les lignes d'historique restent visibles ; 0 les ignore (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Cliquer sur des objets superposés ouvre un sélecteur + .block-edit = Édition de bloc + .double-click-to-edit-block-in-place-refedit = Double-clic pour modifier le bloc sur place (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Double-clic sur les blocs avec attributs pour les modifier (ATTEDIT) + .user-preferences = Préférences utilisateur + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Inverser le zoom à la molette (ZOOMWHEEL) + .zoom-factor = Facteur de zoom + .how-far-one-wheel-notch-zooms-zoomfactor = De combien zoome un cran de molette (ZOOMFACTOR). + .text-and-dimensions = Texte et cotation + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT modifie un objet puis se termine (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Les cotes continues héritent du calque et du style de la cote de base (DIMCONTINUEMODE) + .qdim-origin-priority = Priorité d'origine QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Depuis quels points QDIM mesure. Réglable aussi dans la commande. + .add-scales-automatically = Ajouter les échelles automatiquement + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Quels objets annotatifs adoptent une nouvelle échelle d'annotation (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Unités de longueur, d'angle et d'insertion + .drawing-units-2 = Unités du dessin… + .drafting = Dessin + .drafting-rotation = Rotation du dessin + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Fait pivoter le réticule et la grille d'accrochage dans le SCU actif (SNAPANG). + .polar-tracking-increment = Incrément de repérage polaire + .snap-and-grid = Accrochage et grille + .snap-modes-grid-spacing-and-object-snap = Modes d'accrochage, pas de la grille et accrochage aux objets + .drafting-settings-2 = Paramètres de dessin… + .modeling-3d = Modélisation 3D + .display-tools = Outils d'affichage + .show-the-navigation-cube-navvcube = Afficher le cube de navigation (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Afficher l'icône SCU (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Placer l'icône SCU à l'origine (UCSICON ORigin) + .display-resolution = Résolution d'affichage + .applies-to-the-current-drawing = s'applique au dessin en cours + .isolines-per-surface = Isolignes par surface + .show-silhouette-edges-on-solids-dispsilh = Afficher les arêtes de silhouette sur les solides (DISPSILH) + .surface-density-u = Densité de surface U + .surface-density-v = Densité de surface V + .surface-type = Type de surface + .record-the-history-of-composite-solids-solidhist = Enregistrer l'historique des solides composés (SOLIDHIST) + .show-solid-history = Afficher l'historique des solides + .not-available = Non disponible + .open-folder = Ouvrir le dossier + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Où l'application range ses propres fichiers. Ces emplacements sont fixes. + .configuration = Configuration + .plot-styles = Styles de tracé + .autosave-files = Fichiers d'enregistrement automatique .selection-modes = Modes de sélection .use-shift-to-add-to-selection-pickadd = Utiliser Maj pour ajouter à la sélection (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Le cliquer-glisser trace un rectangle au lieu d'un lasso (PICKDRAG) diff --git a/locales/hi-IN/opencadstudio.ftl b/locales/hi-IN/opencadstudio.ftl index a2a6224b3..6f6f276e0 100644 --- a/locales/hi-IN/opencadstudio.ftl +++ b/locales/hi-IN/opencadstudio.ftl @@ -1364,6 +1364,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = दिखने वाले चयन बॉक्स और क्लिक क्षेत्र दोनों को नियंत्रित करता है। .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = अनुप्रयोग + .installed-plugins-and-their-sources = स्थापित प्लगइन और उनके स्रोत + .plugins-2 = प्लगइन… + .keyboard-shortcuts-2 = कीबोर्ड शॉर्टकट + .keyboard-shortcuts-3 = कीबोर्ड शॉर्टकट… + .command-aliases-2 = कमांड उपनाम + .command-aliases-3 = कमांड उपनाम… + .file-safety = फ़ाइल सुरक्षा + .automatic-save = स्वतः सहेजना + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = .sv$ फ़ाइल में पुनर्प्राप्ति सहेजने के बीच मिनट; 0 इसे बंद करता है (SAVETIME)। + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = ड्रॉइंग को अधिलेखित करते समय .bak प्रति रखें (ISAVEBAK) + .plotting = प्लॉटिंग + .plot-device-paper-scale-and-plot-styles = प्लॉट डिवाइस, कागज़, स्केल और प्लॉट शैलियाँ + .plot-and-page-setup = प्लॉट और पेज सेटअप… + .fill-truetype-glyphs-textfill = TrueType ग्लिफ़ भरें (TEXTFILL) + .command-line = कमांड लाइन + .prompt-lines = प्रॉम्प्ट पंक्तियाँ + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = कमांड विंडो के ऊपर अस्थायी प्रॉम्प्ट पंक्तियाँ (CLIPROMPTLINES)। + .history-fade-time = इतिहास फ़ेड समय + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = इतिहास पंक्तियाँ कितनी देर दिखती हैं; 0 उन्हें छोड़ देता है (COMMANDLINEFADETIME)। + .clicking-overlapping-objects-opens-a-picker = अतिव्यापी ऑब्जेक्ट पर क्लिक करने से चयनकर्ता खुलता है + .block-edit = ब्लॉक संपादन + .double-click-to-edit-block-in-place-refedit = ब्लॉक को यथास्थान संपादित करने के लिए डबल-क्लिक करें (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = विशेषता वाले ब्लॉक पर डबल-क्लिक कर विशेषताएँ संपादित करें (ATTEDIT) + .user-preferences = उपयोगकर्ता प्राथमिकताएँ + .zoom = ज़ूम + .reverse-mouse-wheel-zoom-zoomwheel = माउस व्हील ज़ूम उलटें (ZOOMWHEEL) + .zoom-factor = ज़ूम गुणक + .how-far-one-wheel-notch-zooms-zoomfactor = व्हील का एक पायदान कितना ज़ूम करता है (ZOOMFACTOR)। + .text-and-dimensions = पाठ और आयाम + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT एक ऑब्जेक्ट संपादित कर समाप्त होता है (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = निरंतर आयाम आधार आयाम की लेयर और शैली प्राप्त करते हैं (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM मूल प्राथमिकता + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM किन बिंदुओं से मापता है। कमांड के भीतर भी सेट किया जा सकता है। + .add-scales-automatically = स्केल स्वतः जोड़ें + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = कौन-से एनोटेटिव ऑब्जेक्ट नया एनोटेशन स्केल लेते हैं (ANNOAUTOSCALE)। + .length-angle-and-insertion-units = लंबाई, कोण और सम्मिलन इकाइयाँ + .drawing-units-2 = ड्रॉइंग इकाइयाँ… + .drafting = ड्राफ्टिंग + .drafting-rotation = ड्राफ्टिंग घूर्णन + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = सक्रिय UCS में क्रॉसहेयर और स्नैप ग्रिड घुमाता है (SNAPANG)। + .polar-tracking-increment = ध्रुवीय ट्रैकिंग वृद्धि + .snap-and-grid = स्नैप और ग्रिड + .snap-modes-grid-spacing-and-object-snap = स्नैप मोड, ग्रिड अंतराल और ऑब्जेक्ट स्नैप + .drafting-settings-2 = ड्राफ्टिंग सेटिंग्स… + .modeling-3d = 3D मॉडलिंग + .display-tools = प्रदर्शन उपकरण + .show-the-navigation-cube-navvcube = नेविगेशन क्यूब दिखाएँ (NAVVCUBE) + .show-the-ucs-icon-ucsicon = UCS आइकन दिखाएँ (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = UCS आइकन मूल बिंदु पर रखें (UCSICON ORigin) + .display-resolution = प्रदर्शन विभेदन + .applies-to-the-current-drawing = वर्तमान ड्रॉइंग पर लागू + .isolines-per-surface = प्रति सतह आइसोलाइन + .show-silhouette-edges-on-solids-dispsilh = ठोसों पर सिल्हूट किनारे दिखाएँ (DISPSILH) + .surface-density-u = सतह घनत्व U + .surface-density-v = सतह घनत्व V + .surface-type = सतह प्रकार + .record-the-history-of-composite-solids-solidhist = संयुक्त ठोसों का इतिहास रिकॉर्ड करें (SOLIDHIST) + .show-solid-history = ठोस इतिहास दिखाएँ + .not-available = उपलब्ध नहीं + .open-folder = फ़ोल्डर खोलें + .where-the-application-keeps-its-own-files-these-locations-are-fixed = अनुप्रयोग अपनी फ़ाइलें कहाँ रखता है। ये स्थान निश्चित हैं। + .configuration = विन्यास + .plot-styles = प्लॉट शैलियाँ + .autosave-files = स्वतः सहेजी फ़ाइलें .selection-modes = चयन मोड .use-shift-to-add-to-selection-pickadd = चयन में जोड़ने के लिए Shift का उपयोग करें (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = दबाकर खींचने पर लैस्सो के बजाय आयत बनता है (PICKDRAG) diff --git a/locales/hu-HU/opencadstudio.ftl b/locales/hu-HU/opencadstudio.ftl index 373ac32e8..9f8e66a81 100644 --- a/locales/hu-HU/opencadstudio.ftl +++ b/locales/hu-HU/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = A látható kijelölőnégyzetet és a kattintási területet is szabályozza. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Alkalmazások + .installed-plugins-and-their-sources = Telepített bővítmények és forrásaik + .plugins-2 = Bővítmények… + .keyboard-shortcuts-2 = Billentyűparancsok + .keyboard-shortcuts-3 = Billentyűparancsok… + .command-aliases-2 = Parancs-álnevek + .command-aliases-3 = Parancs-álnevek… + .file-safety = Fájlbiztonság + .automatic-save = Automatikus mentés + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Percek a .sv$ helyreállítási mentések között; a 0 kikapcsolja (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Rajz felülírásakor .bak másolat megőrzése (ISAVEBAK) + .plotting = Nyomtatás + .plot-device-paper-scale-and-plot-styles = Nyomtatóeszköz, papír, méretarány és nyomtatási stílusok + .plot-and-page-setup = Nyomtatás és oldalbeállítás… + .fill-truetype-glyphs-textfill = TrueType karakterek kitöltése (TEXTFILL) + .command-line = Parancssor + .prompt-lines = Kérdéssorok + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Ideiglenes kérdéssorok a parancsablak fölött (CLIPROMPTLINES). + .history-fade-time = Előzmények elhalványulási ideje + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Meddig maradnak láthatók az előzménysorok; a 0 kihagyja őket (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Átfedő objektumokra kattintva választó nyílik + .block-edit = Blokkszerkesztés + .double-click-to-edit-block-in-place-refedit = Dupla kattintás a blokk helyben szerkesztéséhez (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Dupla kattintás az attribútumos blokkok attribútumainak szerkesztéséhez (ATTEDIT) + .user-preferences = Felhasználói beállítások + .zoom = Nagyítás + .reverse-mouse-wheel-zoom-zoomwheel = Egérgörgős nagyítás megfordítása (ZOOMWHEEL) + .zoom-factor = Nagyítási tényező + .how-far-one-wheel-notch-zooms-zoomfactor = Mennyit nagyít egy görgőkattanás (ZOOMFACTOR). + .text-and-dimensions = Szöveg és méretezés + .textedit-edits-one-object-and-ends-texteditmode = A TEXTEDIT egy objektumot szerkeszt, majd befejezi (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = A folytatott méretek öröklik az alapméret fóliáját és stílusát (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM kiindulási prioritás + .which-points-qdim-measures-from-also-settable-inside-the-command = Mely pontoktól mér a QDIM. A parancson belül is beállítható. + .add-scales-automatically = Méretarányok automatikus hozzáadása + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Mely annotatív objektumok veszik át az újonnan beállított feliratozási méretarányt (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Hossz-, szög- és beszúrási egységek + .drawing-units-2 = Rajzegységek… + .drafting = Rajzolás + .drafting-rotation = Rajzolási elforgatás + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Elforgatja a szálkeresztet és az illesztőrácsot az aktív FKR-ben (SNAPANG). + .polar-tracking-increment = Poláris követési lépés + .snap-and-grid = Illesztés és rács + .snap-modes-grid-spacing-and-object-snap = Illesztési módok, rácstávolság és tárgyraszter + .drafting-settings-2 = Rajzolási beállítások… + .modeling-3d = 3D modellezés + .display-tools = Megjelenítési eszközök + .show-the-navigation-cube-navvcube = Navigációs kocka megjelenítése (NAVVCUBE) + .show-the-ucs-icon-ucsicon = FKR ikon megjelenítése (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = FKR ikon elhelyezése az origóban (UCSICON ORigin) + .display-resolution = Megjelenítési felbontás + .applies-to-the-current-drawing = az aktuális rajzra vonatkozik + .isolines-per-surface = Izovonalak felületenként + .show-silhouette-edges-on-solids-dispsilh = Sziluettélek megjelenítése testeken (DISPSILH) + .surface-density-u = Felületsűrűség U + .surface-density-v = Felületsűrűség V + .surface-type = Felülettípus + .record-the-history-of-composite-solids-solidhist = Összetett testek előzményeinek rögzítése (SOLIDHIST) + .show-solid-history = Testelőzmények megjelenítése + .not-available = Nem érhető el + .open-folder = Mappa megnyitása + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Hol tárolja az alkalmazás a saját fájljait. Ezek a helyek rögzítettek. + .configuration = Konfiguráció + .plot-styles = Nyomtatási stílusok + .autosave-files = Automatikus mentés fájljai .selection-modes = Kijelölési módok .use-shift-to-add-to-selection-pickadd = Shift használata a kijelöléshez adáshoz (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = A lenyomva húzás téglalapot rajzol lasszó helyett (PICKDRAG) diff --git a/locales/it-IT/opencadstudio.ftl b/locales/it-IT/opencadstudio.ftl index f2fb32860..264c68dc2 100644 --- a/locales/it-IT/opencadstudio.ftl +++ b/locales/it-IT/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controlla sia il riquadro di selezione visibile sia l’area di clic. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Applicazioni + .installed-plugins-and-their-sources = Plug-in installati e relative origini + .plugins-2 = Plug-in… + .keyboard-shortcuts-2 = Scorciatoie da tastiera + .keyboard-shortcuts-3 = Scorciatoie da tastiera… + .command-aliases-2 = Alias dei comandi + .command-aliases-3 = Alias dei comandi… + .file-safety = Sicurezza dei file + .automatic-save = Salvataggio automatico + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuti tra i salvataggi di ripristino in un file .sv$; 0 lo disattiva (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Conserva una copia .bak quando si sovrascrive un disegno (ISAVEBAK) + .plotting = Stampa + .plot-device-paper-scale-and-plot-styles = Dispositivo di stampa, foglio, scala e stili di stampa + .plot-and-page-setup = Stampa e imposta pagina… + .fill-truetype-glyphs-textfill = Riempi i glifi TrueType (TEXTFILL) + .command-line = Riga di comando + .prompt-lines = Righe di richiesta + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Righe di richiesta temporanee sopra la finestra dei comandi (CLIPROMPTLINES). + .history-fade-time = Tempo di dissolvenza della cronologia + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Per quanto restano visibili le righe di cronologia; 0 le salta (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Fare clic su oggetti sovrapposti apre un selettore + .block-edit = Modifica blocco + .double-click-to-edit-block-in-place-refedit = Doppio clic per modificare il blocco sul posto (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Doppio clic sui blocchi con attributi per modificarli (ATTEDIT) + .user-preferences = Preferenze utente + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Inverti lo zoom con la rotellina (ZOOMWHEEL) + .zoom-factor = Fattore di zoom + .how-far-one-wheel-notch-zooms-zoomfactor = Di quanto ingrandisce uno scatto della rotellina (ZOOMFACTOR). + .text-and-dimensions = Testo e quote + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT modifica un oggetto e termina (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Le quote continue ereditano layer e stile dalla quota di base (DIMCONTINUEMODE) + .qdim-origin-priority = Priorità di origine QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Da quali punti misura QDIM. Impostabile anche dentro il comando. + .add-scales-automatically = Aggiungi scale automaticamente + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Quali oggetti annotativi adottano una scala di annotazione appena impostata (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Unità di lunghezza, angolo e inserimento + .drawing-units-2 = Unità del disegno… + .drafting = Disegno + .drafting-rotation = Rotazione di disegno + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Ruota il mirino e la griglia di snap nell'UCS attivo (SNAPANG). + .polar-tracking-increment = Incremento di puntamento polare + .snap-and-grid = Snap e griglia + .snap-modes-grid-spacing-and-object-snap = Modalità di snap, passo della griglia e snap agli oggetti + .drafting-settings-2 = Impostazioni disegno… + .modeling-3d = Modellazione 3D + .display-tools = Strumenti di visualizzazione + .show-the-navigation-cube-navvcube = Mostra il cubo di navigazione (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Mostra l'icona UCS (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Colloca l'icona UCS nell'origine (UCSICON ORigin) + .display-resolution = Risoluzione di visualizzazione + .applies-to-the-current-drawing = si applica al disegno corrente + .isolines-per-surface = Isolinee per superficie + .show-silhouette-edges-on-solids-dispsilh = Mostra i bordi di silhouette sui solidi (DISPSILH) + .surface-density-u = Densità superficie U + .surface-density-v = Densità superficie V + .surface-type = Tipo di superficie + .record-the-history-of-composite-solids-solidhist = Registra la cronologia dei solidi composti (SOLIDHIST) + .show-solid-history = Mostra la cronologia dei solidi + .not-available = Non disponibile + .open-folder = Apri cartella + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Dove l'applicazione tiene i propri file. Queste posizioni sono fisse. + .configuration = Configurazione + .plot-styles = Stili di stampa + .autosave-files = File di salvataggio automatico .selection-modes = Modalità di selezione .use-shift-to-add-to-selection-pickadd = Usa Maiusc per aggiungere alla selezione (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Premi e trascina disegna un rettangolo invece di un lazo (PICKDRAG) diff --git a/locales/ja-JP/opencadstudio.ftl b/locales/ja-JP/opencadstudio.ftl index d6d37b14a..a1e8ace50 100644 --- a/locales/ja-JP/opencadstudio.ftl +++ b/locales/ja-JP/opencadstudio.ftl @@ -1366,6 +1366,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 表示される選択ボックスとクリック範囲の両方を制御します。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = アプリケーション + .installed-plugins-and-their-sources = インストール済みプラグインとその提供元 + .plugins-2 = プラグイン… + .keyboard-shortcuts-2 = キーボード ショートカット + .keyboard-shortcuts-3 = キーボード ショートカット… + .command-aliases-2 = コマンド エイリアス + .command-aliases-3 = コマンド エイリアス… + .file-safety = ファイルの安全性 + .automatic-save = 自動保存 + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = .sv$ ファイルへの復旧保存の間隔 (分)。0 で無効 (SAVETIME)。 + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = 図面を上書きするとき .bak のコピーを残す (ISAVEBAK) + .plotting = 印刷 + .plot-device-paper-scale-and-plot-styles = 印刷デバイス、用紙、尺度、印刷スタイル + .plot-and-page-setup = 印刷とページ設定… + .fill-truetype-glyphs-textfill = TrueType グリフを塗りつぶす (TEXTFILL) + .command-line = コマンド ライン + .prompt-lines = プロンプト行 + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = コマンド ウィンドウの上に表示する一時プロンプト行 (CLIPROMPTLINES)。 + .history-fade-time = 履歴のフェード時間 + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = 履歴行が表示され続ける時間。0 で表示しない (COMMANDLINEFADETIME)。 + .clicking-overlapping-objects-opens-a-picker = 重なったオブジェクトをクリックすると選択リストが開く + .block-edit = ブロック編集 + .double-click-to-edit-block-in-place-refedit = ダブルクリックでブロックをその場で編集 (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = 属性付きブロックをダブルクリックして属性を編集 (ATTEDIT) + .user-preferences = ユーザー設定 + .zoom = ズーム + .reverse-mouse-wheel-zoom-zoomwheel = マウス ホイールのズーム方向を反転 (ZOOMWHEEL) + .zoom-factor = ズーム倍率 + .how-far-one-wheel-notch-zooms-zoomfactor = ホイール 1 ノッチのズーム量 (ZOOMFACTOR)。 + .text-and-dimensions = 文字と寸法 + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT は 1 つのオブジェクトを編集して終了 (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = 連続寸法は基準寸法の画層とスタイルを引き継ぐ (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM の起点優先 + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM が測定する点。コマンド内でも設定できます。 + .add-scales-automatically = 尺度を自動的に追加 + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = 新しく設定した注釈尺度を受け取る注釈オブジェクト (ANNOAUTOSCALE)。 + .length-angle-and-insertion-units = 長さ、角度、挿入の単位 + .drawing-units-2 = 図面単位… + .drafting = 作図 + .drafting-rotation = 作図の回転 + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = アクティブな UCS でクロスヘアとスナップ グリッドを回転します (SNAPANG)。 + .polar-tracking-increment = 極トラッキングの増分 + .snap-and-grid = スナップとグリッド + .snap-modes-grid-spacing-and-object-snap = スナップ モード、グリッド間隔、オブジェクト スナップ + .drafting-settings-2 = 作図設定… + .modeling-3d = 3D モデリング + .display-tools = 表示ツール + .show-the-navigation-cube-navvcube = ナビゲーション キューブを表示 (NAVVCUBE) + .show-the-ucs-icon-ucsicon = UCS アイコンを表示 (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = UCS アイコンを原点に配置 (UCSICON ORigin) + .display-resolution = 表示解像度 + .applies-to-the-current-drawing = 現在の図面に適用されます + .isolines-per-surface = 面あたりのアイソライン + .show-silhouette-edges-on-solids-dispsilh = ソリッドのシルエット エッジを表示 (DISPSILH) + .surface-density-u = サーフェス密度 U + .surface-density-v = サーフェス密度 V + .surface-type = サーフェス タイプ + .record-the-history-of-composite-solids-solidhist = 複合ソリッドの履歴を記録 (SOLIDHIST) + .show-solid-history = ソリッドの履歴を表示 + .not-available = 利用できません + .open-folder = フォルダーを開く + .where-the-application-keeps-its-own-files-these-locations-are-fixed = アプリケーションが自身のファイルを置く場所です。これらの場所は固定です。 + .configuration = 構成 + .plot-styles = 印刷スタイル + .autosave-files = 自動保存ファイル .selection-modes = 選択モード .use-shift-to-add-to-selection-pickadd = Shift キーで選択に追加する (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = ドラッグで投げ縄ではなく矩形を描く (PICKDRAG) diff --git a/locales/ko-KR/opencadstudio.ftl b/locales/ko-KR/opencadstudio.ftl index c4423adb6..2a1c41d76 100644 --- a/locales/ko-KR/opencadstudio.ftl +++ b/locales/ko-KR/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 표시되는 선택 상자와 클릭 영역을 모두 제어합니다. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = 응용 프로그램 + .installed-plugins-and-their-sources = 설치된 플러그인 및 해당 소스 + .plugins-2 = 플러그인… + .keyboard-shortcuts-2 = 키보드 단축키 + .keyboard-shortcuts-3 = 키보드 단축키… + .command-aliases-2 = 명령 별칭 + .command-aliases-3 = 명령 별칭… + .file-safety = 파일 안전 + .automatic-save = 자동 저장 + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = .sv$ 파일로 복구 저장하는 간격(분). 0이면 해제 (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = 도면을 덮어쓸 때 .bak 사본 유지 (ISAVEBAK) + .plotting = 플롯 + .plot-device-paper-scale-and-plot-styles = 플롯 장치, 용지, 축척 및 플롯 스타일 + .plot-and-page-setup = 플롯 및 페이지 설정… + .fill-truetype-glyphs-textfill = TrueType 글리프 채우기 (TEXTFILL) + .command-line = 명령행 + .prompt-lines = 프롬프트 행 + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = 명령 창 위에 표시할 임시 프롬프트 행 (CLIPROMPTLINES). + .history-fade-time = 히스토리 페이드 시간 + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = 히스토리 행이 표시되는 시간. 0이면 건너뜀 (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = 겹친 객체를 클릭하면 선택기가 열림 + .block-edit = 블록 편집 + .double-click-to-edit-block-in-place-refedit = 두 번 클릭하여 블록을 제자리에서 편집 (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = 속성이 있는 블록을 두 번 클릭하여 속성 편집 (ATTEDIT) + .user-preferences = 사용자 환경설정 + .zoom = 줌 + .reverse-mouse-wheel-zoom-zoomwheel = 마우스 휠 줌 방향 반전 (ZOOMWHEEL) + .zoom-factor = 줌 배율 + .how-far-one-wheel-notch-zooms-zoomfactor = 휠 한 칸이 확대하는 정도 (ZOOMFACTOR). + .text-and-dimensions = 문자 및 치수 + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT는 객체 하나를 편집하고 종료 (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = 연속 치수가 기준 치수의 도면층과 스타일을 상속 (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM 원점 우선순위 + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM이 측정하는 점. 명령 안에서도 설정할 수 있습니다. + .add-scales-automatically = 축척 자동 추가 + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = 새로 설정한 주석 축척을 적용받을 주석 객체 (ANNOAUTOSCALE). + .length-angle-and-insertion-units = 길이, 각도 및 삽입 단위 + .drawing-units-2 = 도면 단위… + .drafting = 제도 + .drafting-rotation = 제도 회전 + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = 활성 UCS에서 십자선과 스냅 그리드를 회전합니다 (SNAPANG). + .polar-tracking-increment = 극좌표 추적 증분 + .snap-and-grid = 스냅 및 그리드 + .snap-modes-grid-spacing-and-object-snap = 스냅 모드, 그리드 간격 및 객체 스냅 + .drafting-settings-2 = 제도 설정… + .modeling-3d = 3D 모델링 + .display-tools = 표시 도구 + .show-the-navigation-cube-navvcube = 탐색 큐브 표시 (NAVVCUBE) + .show-the-ucs-icon-ucsicon = UCS 아이콘 표시 (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = UCS 아이콘을 원점에 배치 (UCSICON ORigin) + .display-resolution = 표시 해상도 + .applies-to-the-current-drawing = 현재 도면에 적용됩니다 + .isolines-per-surface = 곡면당 아이소라인 + .show-silhouette-edges-on-solids-dispsilh = 솔리드의 실루엣 모서리 표시 (DISPSILH) + .surface-density-u = 곡면 밀도 U + .surface-density-v = 곡면 밀도 V + .surface-type = 곡면 유형 + .record-the-history-of-composite-solids-solidhist = 복합 솔리드의 히스토리 기록 (SOLIDHIST) + .show-solid-history = 솔리드 히스토리 표시 + .not-available = 사용할 수 없음 + .open-folder = 폴더 열기 + .where-the-application-keeps-its-own-files-these-locations-are-fixed = 응용 프로그램이 자체 파일을 두는 위치입니다. 이 위치는 고정입니다. + .configuration = 구성 + .plot-styles = 플롯 스타일 + .autosave-files = 자동 저장 파일 .selection-modes = 선택 모드 .use-shift-to-add-to-selection-pickadd = Shift 키로 선택에 추가 (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 누른 채 끌면 올가미 대신 사각형을 그림 (PICKDRAG) diff --git a/locales/nl-NL/opencadstudio.ftl b/locales/nl-NL/opencadstudio.ftl index 1d9c99d55..9f7b7c1fe 100644 --- a/locales/nl-NL/opencadstudio.ftl +++ b/locales/nl-NL/opencadstudio.ftl @@ -1374,6 +1374,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Bepaalt zowel het zichtbare selectievak als het klikgebied. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Toepassingen + .installed-plugins-and-their-sources = Geïnstalleerde plug-ins en hun bronnen + .plugins-2 = Plug-ins… + .keyboard-shortcuts-2 = Sneltoetsen + .keyboard-shortcuts-3 = Sneltoetsen… + .command-aliases-2 = Opdrachtaliassen + .command-aliases-3 = Opdrachtaliassen… + .file-safety = Bestandsveiligheid + .automatic-save = Automatisch opslaan + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuten tussen herstelopslagen naar een .sv$-bestand; 0 schakelt het uit (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Een .bak-kopie bewaren bij het overschrijven van een tekening (ISAVEBAK) + .plotting = Plotten + .plot-device-paper-scale-and-plot-styles = Plotapparaat, papier, schaal en plotstijlen + .plot-and-page-setup = Plotten en pagina-instelling… + .fill-truetype-glyphs-textfill = TrueType-glyphs vullen (TEXTFILL) + .command-line = Opdrachtregel + .prompt-lines = Promptregels + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Tijdelijke promptregels boven het opdrachtvenster (CLIPROMPTLINES). + .history-fade-time = Vervaagtijd van geschiedenis + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Hoe lang geschiedenisregels zichtbaar blijven; 0 slaat ze over (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Klikken op overlappende objecten opent een kiezer + .block-edit = Blok bewerken + .double-click-to-edit-block-in-place-refedit = Dubbelklik om het blok ter plaatse te bewerken (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Dubbelklik op blokken met attributen om die te bewerken (ATTEDIT) + .user-preferences = Gebruikersvoorkeuren + .zoom = Zoomen + .reverse-mouse-wheel-zoom-zoomwheel = Muiswielzoom omkeren (ZOOMWHEEL) + .zoom-factor = Zoomfactor + .how-far-one-wheel-notch-zooms-zoomfactor = Hoeveel één wielklik zoomt (ZOOMFACTOR). + .text-and-dimensions = Tekst en maatvoering + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT bewerkt één object en stopt (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Doorlopende maten erven laag en stijl van de basismaat (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM-oorsprongprioriteit + .which-points-qdim-measures-from-also-settable-inside-the-command = Vanaf welke punten QDIM meet. Ook in te stellen binnen de opdracht. + .add-scales-automatically = Schalen automatisch toevoegen + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Welke annotatieve objecten een nieuw ingestelde annotatieschaal overnemen (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Lengte-, hoek- en invoegeenheden + .drawing-units-2 = Tekeneenheden… + .drafting = Tekenen + .drafting-rotation = Tekenrotatie + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Draait het draadkruis en het vangraster in het actieve BCS (SNAPANG). + .polar-tracking-increment = Polaire volgstap + .snap-and-grid = Vang en raster + .snap-modes-grid-spacing-and-object-snap = Vangmodi, rasterafstand en objectvang + .drafting-settings-2 = Tekeninstellingen… + .modeling-3d = 3D-modelleren + .display-tools = Weergavegereedschappen + .show-the-navigation-cube-navvcube = Navigatiekubus tonen (NAVVCUBE) + .show-the-ucs-icon-ucsicon = BCS-pictogram tonen (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = BCS-pictogram op de oorsprong plaatsen (UCSICON ORigin) + .display-resolution = Weergaveresolutie + .applies-to-the-current-drawing = geldt voor de huidige tekening + .isolines-per-surface = Isolijnen per oppervlak + .show-silhouette-edges-on-solids-dispsilh = Silhouetranden op vaste lichamen tonen (DISPSILH) + .surface-density-u = Oppervlakdichtheid U + .surface-density-v = Oppervlakdichtheid V + .surface-type = Oppervlaktype + .record-the-history-of-composite-solids-solidhist = Geschiedenis van samengestelde vaste lichamen vastleggen (SOLIDHIST) + .show-solid-history = Geschiedenis van vaste lichamen tonen + .not-available = Niet beschikbaar + .open-folder = Map openen + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Waar de toepassing haar eigen bestanden bewaart. Deze locaties liggen vast. + .configuration = Configuratie + .plot-styles = Plotstijlen + .autosave-files = Bestanden voor automatisch opslaan .selection-modes = Selectiemodi .use-shift-to-add-to-selection-pickadd = Shift gebruiken om aan de selectie toe te voegen (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Slepen tekent een rechthoek in plaats van een lasso (PICKDRAG) diff --git a/locales/pl-PL/opencadstudio.ftl b/locales/pl-PL/opencadstudio.ftl index 13bad04bc..03d2eeddd 100644 --- a/locales/pl-PL/opencadstudio.ftl +++ b/locales/pl-PL/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Steruje widocznym polem wyboru oraz obszarem kliknięcia. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Aplikacje + .installed-plugins-and-their-sources = Zainstalowane wtyczki i ich źródła + .plugins-2 = Wtyczki… + .keyboard-shortcuts-2 = Skróty klawiszowe + .keyboard-shortcuts-3 = Skróty klawiszowe… + .command-aliases-2 = Aliasy poleceń + .command-aliases-3 = Aliasy poleceń… + .file-safety = Bezpieczeństwo plików + .automatic-save = Automatyczny zapis + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minuty między zapisami odzyskiwania do pliku .sv$; 0 wyłącza (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Zachowaj kopię .bak przy nadpisywaniu rysunku (ISAVEBAK) + .plotting = Kreślenie + .plot-device-paper-scale-and-plot-styles = Urządzenie kreślące, papier, skala i style wydruku + .plot-and-page-setup = Kreśl i ustawienia strony… + .fill-truetype-glyphs-textfill = Wypełniaj glify TrueType (TEXTFILL) + .command-line = Wiersz poleceń + .prompt-lines = Wiersze zachęty + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Tymczasowe wiersze zachęty nad oknem poleceń (CLIPROMPTLINES). + .history-fade-time = Czas zanikania historii + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Jak długo wiersze historii pozostają widoczne; 0 je pomija (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Kliknięcie nakładających się obiektów otwiera selektor + .block-edit = Edycja bloku + .double-click-to-edit-block-in-place-refedit = Dwukrotne kliknięcie edytuje blok w miejscu (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Dwukrotne kliknięcie bloków z atrybutami edytuje atrybuty (ATTEDIT) + .user-preferences = Preferencje użytkownika + .zoom = Powiększenie + .reverse-mouse-wheel-zoom-zoomwheel = Odwróć powiększanie kółkiem myszy (ZOOMWHEEL) + .zoom-factor = Współczynnik powiększenia + .how-far-one-wheel-notch-zooms-zoomfactor = O ile powiększa jedno kliknięcie kółka (ZOOMFACTOR). + .text-and-dimensions = Tekst i wymiary + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT edytuje jeden obiekt i kończy (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Wymiary kontynuowane dziedziczą warstwę i styl wymiaru bazowego (DIMCONTINUEMODE) + .qdim-origin-priority = Priorytet początku QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = Od których punktów mierzy QDIM. Ustawiane też wewnątrz polecenia. + .add-scales-automatically = Dodawaj skale automatycznie + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Które obiekty adnotacyjne przyjmują nowo ustawioną skalę adnotacji (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Jednostki długości, kąta i wstawiania + .drawing-units-2 = Jednostki rysunku… + .drafting = Kreślenie ręczne + .drafting-rotation = Obrót rysowania + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Obraca celownik i siatkę przyciągania w aktywnym LUW (SNAPANG). + .polar-tracking-increment = Przyrost śledzenia biegunowego + .snap-and-grid = Przyciąganie i siatka + .snap-modes-grid-spacing-and-object-snap = Tryby przyciągania, odstęp siatki i przyciąganie do obiektów + .drafting-settings-2 = Ustawienia rysowania… + .modeling-3d = Modelowanie 3D + .display-tools = Narzędzia wyświetlania + .show-the-navigation-cube-navvcube = Pokaż kostkę nawigacji (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Pokaż ikonę LUW (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Umieść ikonę LUW w początku układu (UCSICON ORigin) + .display-resolution = Rozdzielczość wyświetlania + .applies-to-the-current-drawing = dotyczy bieżącego rysunku + .isolines-per-surface = Izolinie na powierzchnię + .show-silhouette-edges-on-solids-dispsilh = Pokaż krawędzie sylwetki na bryłach (DISPSILH) + .surface-density-u = Gęstość powierzchni U + .surface-density-v = Gęstość powierzchni V + .surface-type = Typ powierzchni + .record-the-history-of-composite-solids-solidhist = Zapisuj historię brył złożonych (SOLIDHIST) + .show-solid-history = Pokaż historię brył + .not-available = Niedostępne + .open-folder = Otwórz folder + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Gdzie aplikacja przechowuje własne pliki. Te lokalizacje są stałe. + .configuration = Konfiguracja + .plot-styles = Style wydruku + .autosave-files = Pliki automatycznego zapisu .selection-modes = Tryby zaznaczania .use-shift-to-add-to-selection-pickadd = Użyj klawisza Shift, aby dodać do zaznaczenia (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Przeciąganie rysuje prostokąt zamiast lassa (PICKDRAG) diff --git a/locales/pt-BR/opencadstudio.ftl b/locales/pt-BR/opencadstudio.ftl index 58cf1b3d3..fefe16cea 100644 --- a/locales/pt-BR/opencadstudio.ftl +++ b/locales/pt-BR/opencadstudio.ftl @@ -1376,6 +1376,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Controla tanto a caixa de seleção visível quanto a área de clique. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Aplicativos + .installed-plugins-and-their-sources = Plug-ins instalados e suas origens + .plugins-2 = Plug-ins… + .keyboard-shortcuts-2 = Atalhos de teclado + .keyboard-shortcuts-3 = Atalhos de teclado… + .command-aliases-2 = Apelidos de comandos + .command-aliases-3 = Apelidos de comandos… + .file-safety = Segurança de arquivos + .automatic-save = Salvamento automático + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Minutos entre salvamentos de recuperação em um arquivo .sv$; 0 desativa (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Manter uma cópia .bak ao sobrescrever um desenho (ISAVEBAK) + .plotting = Plotagem + .plot-device-paper-scale-and-plot-styles = Dispositivo de plotagem, papel, escala e estilos de plotagem + .plot-and-page-setup = Plotar e configurar página… + .fill-truetype-glyphs-textfill = Preencher glifos TrueType (TEXTFILL) + .command-line = Linha de comando + .prompt-lines = Linhas de solicitação + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Linhas de solicitação temporárias acima da janela de comandos (CLIPROMPTLINES). + .history-fade-time = Tempo de esmaecimento do histórico + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Quanto tempo as linhas de histórico ficam visíveis; 0 as ignora (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Clicar em objetos sobrepostos abre um seletor + .block-edit = Edição de bloco + .double-click-to-edit-block-in-place-refedit = Duplo clique para editar o bloco no local (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Duplo clique em blocos com atributos para editá-los (ATTEDIT) + .user-preferences = Preferências do usuário + .zoom = Zoom + .reverse-mouse-wheel-zoom-zoomwheel = Inverter o zoom da roda do mouse (ZOOMWHEEL) + .zoom-factor = Fator de zoom + .how-far-one-wheel-notch-zooms-zoomfactor = Quanto um passo da roda amplia (ZOOMFACTOR). + .text-and-dimensions = Texto e cotas + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT edita um objeto e encerra (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Cotas continuadas herdam camada e estilo da cota base (DIMCONTINUEMODE) + .qdim-origin-priority = Prioridade de origem do QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = De quais pontos o QDIM mede. Também ajustável dentro do comando. + .add-scales-automatically = Adicionar escalas automaticamente + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Quais objetos anotativos recebem uma escala de anotação recém-definida (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Unidades de comprimento, ângulo e inserção + .drawing-units-2 = Unidades do desenho… + .drafting = Desenho + .drafting-rotation = Rotação de desenho + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Gira a mira e a grade de precisão no UCS ativo (SNAPANG). + .polar-tracking-increment = Incremento de rastreamento polar + .snap-and-grid = Precisão e grade + .snap-modes-grid-spacing-and-object-snap = Modos de precisão, espaçamento da grade e osnap + .drafting-settings-2 = Configurações de desenho… + .modeling-3d = Modelagem 3D + .display-tools = Ferramentas de exibição + .show-the-navigation-cube-navvcube = Mostrar o cubo de navegação (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Mostrar o ícone UCS (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Colocar o ícone UCS na origem (UCSICON ORigin) + .display-resolution = Resolução de exibição + .applies-to-the-current-drawing = aplica-se ao desenho atual + .isolines-per-surface = Isolinhas por superfície + .show-silhouette-edges-on-solids-dispsilh = Mostrar arestas de silhueta em sólidos (DISPSILH) + .surface-density-u = Densidade de superfície U + .surface-density-v = Densidade de superfície V + .surface-type = Tipo de superfície + .record-the-history-of-composite-solids-solidhist = Registrar o histórico de sólidos compostos (SOLIDHIST) + .show-solid-history = Mostrar o histórico de sólidos + .not-available = Não disponível + .open-folder = Abrir pasta + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Onde o aplicativo guarda seus próprios arquivos. Esses locais são fixos. + .configuration = Configuração + .plot-styles = Estilos de plotagem + .autosave-files = Arquivos de salvamento automático .selection-modes = Modos de seleção .use-shift-to-add-to-selection-pickadd = Usar Shift para adicionar à seleção (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Pressionar e arrastar desenha um retângulo em vez de um laço (PICKDRAG) diff --git a/locales/ru-RU/opencadstudio.ftl b/locales/ru-RU/opencadstudio.ftl index 13ba008b2..8c0d1656f 100644 --- a/locales/ru-RU/opencadstudio.ftl +++ b/locales/ru-RU/opencadstudio.ftl @@ -1379,6 +1379,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Управляет видимым прицелом выбора и областью щелчка. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Приложения + .installed-plugins-and-their-sources = Установленные плагины и их источники + .plugins-2 = Плагины… + .keyboard-shortcuts-2 = Сочетания клавиш + .keyboard-shortcuts-3 = Сочетания клавиш… + .command-aliases-2 = Псевдонимы команд + .command-aliases-3 = Псевдонимы команд… + .file-safety = Безопасность файлов + .automatic-save = Автосохранение + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Минуты между сохранениями восстановления в файл .sv$; 0 отключает (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Сохранять копию .bak при перезаписи чертежа (ISAVEBAK) + .plotting = Печать + .plot-device-paper-scale-and-plot-styles = Устройство печати, бумага, масштаб и стили печати + .plot-and-page-setup = Печать и параметры листа… + .fill-truetype-glyphs-textfill = Заливать глифы TrueType (TEXTFILL) + .command-line = Командная строка + .prompt-lines = Строки запроса + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Временные строки запроса над окном команд (CLIPROMPTLINES). + .history-fade-time = Время затухания журнала + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Как долго строки журнала остаются видимыми; 0 их пропускает (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Щелчок по перекрывающимся объектам открывает список выбора + .block-edit = Редактирование блока + .double-click-to-edit-block-in-place-refedit = Двойной щелчок редактирует блок на месте (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Двойной щелчок по блокам с атрибутами редактирует атрибуты (ATTEDIT) + .user-preferences = Пользовательские настройки + .zoom = Зумирование + .reverse-mouse-wheel-zoom-zoomwheel = Обратить зумирование колесом мыши (ZOOMWHEEL) + .zoom-factor = Коэффициент зумирования + .how-far-one-wheel-notch-zooms-zoomfactor = Насколько приближает один щелчок колеса (ZOOMFACTOR). + .text-and-dimensions = Текст и размеры + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT редактирует один объект и завершается (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Продолженные размеры наследуют слой и стиль базового размера (DIMCONTINUEMODE) + .qdim-origin-priority = Приоритет начала QDIM + .which-points-qdim-measures-from-also-settable-inside-the-command = От каких точек измеряет QDIM. Настраивается и внутри команды. + .add-scales-automatically = Добавлять масштабы автоматически + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Какие аннотативные объекты принимают вновь заданный масштаб аннотаций (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Единицы длины, угла и вставки + .drawing-units-2 = Единицы чертежа… + .drafting = Черчение + .drafting-rotation = Поворот черчения + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Поворачивает перекрестье и сетку привязки в активной ПСК (SNAPANG). + .polar-tracking-increment = Шаг полярного отслеживания + .snap-and-grid = Привязка и сетка + .snap-modes-grid-spacing-and-object-snap = Режимы привязки, шаг сетки и объектная привязка + .drafting-settings-2 = Параметры черчения… + .modeling-3d = 3D-моделирование + .display-tools = Инструменты отображения + .show-the-navigation-cube-navvcube = Показывать куб навигации (NAVVCUBE) + .show-the-ucs-icon-ucsicon = Показывать значок ПСК (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = Размещать значок ПСК в начале координат (UCSICON ORigin) + .display-resolution = Разрешение отображения + .applies-to-the-current-drawing = применяется к текущему чертежу + .isolines-per-surface = Изолиний на поверхность + .show-silhouette-edges-on-solids-dispsilh = Показывать силуэтные рёбра тел (DISPSILH) + .surface-density-u = Плотность поверхности U + .surface-density-v = Плотность поверхности V + .surface-type = Тип поверхности + .record-the-history-of-composite-solids-solidhist = Записывать историю составных тел (SOLIDHIST) + .show-solid-history = Показывать историю тел + .not-available = Недоступно + .open-folder = Открыть папку + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Где приложение хранит собственные файлы. Эти расположения фиксированы. + .configuration = Конфигурация + .plot-styles = Стили печати + .autosave-files = Файлы автосохранения .selection-modes = Режимы выбора .use-shift-to-add-to-selection-pickadd = Использовать Shift для добавления к выбору (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Нажатие и перетаскивание рисует прямоугольник вместо лассо (PICKDRAG) diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index 0ffa766d1..c1e06f91a 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -1362,6 +1362,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = Hem görünür seçim kutusunu hem de tıklama alanını denetler. .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = Uygulamalar + .installed-plugins-and-their-sources = Yüklü eklentiler ve kaynakları + .plugins-2 = Eklentiler… + .keyboard-shortcuts-2 = Klavye kısayolları + .keyboard-shortcuts-3 = Klavye kısayolları… + .command-aliases-2 = Komut takma adları + .command-aliases-3 = Komut takma adları… + .file-safety = Dosya güvenliği + .automatic-save = Otomatik kaydetme + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = Bir .sv$ dosyasına kurtarma kayıtları arasındaki dakika; 0 kapatır (SAVETIME). + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = Bir çizimin üzerine yazarken .bak kopyası tut (ISAVEBAK) + .plotting = Çizdirme + .plot-device-paper-scale-and-plot-styles = Çizim aygıtı, kâğıt, ölçek ve çizim stilleri + .plot-and-page-setup = Çizdir ve sayfa düzeni… + .fill-truetype-glyphs-textfill = TrueType karakterlerini doldur (TEXTFILL) + .command-line = Komut satırı + .prompt-lines = İstem satırları + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = Komut penceresinin üstündeki geçici istem satırları (CLIPROMPTLINES). + .history-fade-time = Geçmiş solma süresi + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = Geçmiş satırlarının ne kadar görünür kalacağı; 0 atlar (COMMANDLINEFADETIME). + .clicking-overlapping-objects-opens-a-picker = Üst üste binen nesnelere tıklamak bir seçici açar + .block-edit = Blok düzenleme + .double-click-to-edit-block-in-place-refedit = Bloğu yerinde düzenlemek için çift tıklayın (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = Öznitelikli bloklara çift tıklayarak özniteliklerini düzenleyin (ATTEDIT) + .user-preferences = Kullanıcı tercihleri + .zoom = Yakınlaştırma + .reverse-mouse-wheel-zoom-zoomwheel = Fare tekerleği yakınlaştırmasını ters çevir (ZOOMWHEEL) + .zoom-factor = Yakınlaştırma çarpanı + .how-far-one-wheel-notch-zooms-zoomfactor = Bir tekerlek adımının ne kadar yakınlaştıracağı (ZOOMFACTOR). + .text-and-dimensions = Metin ve ölçülendirme + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT bir nesneyi düzenler ve biter (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = Devam eden ölçüler temel ölçünün katmanını ve stilini devralır (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM başlangıç önceliği + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM'in hangi noktalardan ölçtüğü. Komutun içinden de ayarlanır. + .add-scales-automatically = Ölçekleri otomatik ekle + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = Yeni ayarlanan bir açıklama ölçeğini hangi açıklamalı nesnelerin alacağı (ANNOAUTOSCALE). + .length-angle-and-insertion-units = Uzunluk, açı ve ekleme birimleri + .drawing-units-2 = Çizim birimleri… + .drafting = Taslak + .drafting-rotation = Taslak dönüşü + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = Etkin UCS'de artı imleci ve yakalama ızgarasını döndürür (SNAPANG). + .polar-tracking-increment = Kutupsal izleme artışı + .snap-and-grid = Yakalama ve ızgara + .snap-modes-grid-spacing-and-object-snap = Yakalama modları, ızgara aralığı ve nesne yakalama + .drafting-settings-2 = Taslak ayarları… + .modeling-3d = 3B modelleme + .display-tools = Görüntüleme araçları + .show-the-navigation-cube-navvcube = Gezinme küpünü göster (NAVVCUBE) + .show-the-ucs-icon-ucsicon = UCS simgesini göster (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = UCS simgesini başlangıç noktasına yerleştir (UCSICON ORigin) + .display-resolution = Görüntüleme çözünürlüğü + .applies-to-the-current-drawing = geçerli çizim için geçerlidir + .isolines-per-surface = Yüzey başına eş çizgiler + .show-silhouette-edges-on-solids-dispsilh = Katılarda siluet kenarlarını göster (DISPSILH) + .surface-density-u = Yüzey yoğunluğu U + .surface-density-v = Yüzey yoğunluğu V + .surface-type = Yüzey türü + .record-the-history-of-composite-solids-solidhist = Bileşik katıların geçmişini kaydet (SOLIDHIST) + .show-solid-history = Katı geçmişini göster + .not-available = Kullanılamıyor + .open-folder = Klasörü aç + .where-the-application-keeps-its-own-files-these-locations-are-fixed = Uygulamanın kendi dosyalarını tuttuğu yer. Bu konumlar sabittir. + .configuration = Yapılandırma + .plot-styles = Çizim stilleri + .autosave-files = Otomatik kaydetme dosyaları .selection-modes = Seçim modları .use-shift-to-add-to-selection-pickadd = Seçime eklemek için Shift kullan (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = Basılı tutup sürükleme kement yerine dikdörtgen çizer (PICKDRAG) diff --git a/locales/zh-CN/opencadstudio.ftl b/locales/zh-CN/opencadstudio.ftl index b737e29f0..e7a4a81a2 100644 --- a/locales/zh-CN/opencadstudio.ftl +++ b/locales/zh-CN/opencadstudio.ftl @@ -1363,6 +1363,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 同时控制可见选择框和单击范围。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = 应用程序 + .installed-plugins-and-their-sources = 已安装的插件及其来源 + .plugins-2 = 插件… + .keyboard-shortcuts-2 = 键盘快捷键 + .keyboard-shortcuts-3 = 键盘快捷键… + .command-aliases-2 = 命令别名 + .command-aliases-3 = 命令别名… + .file-safety = 文件安全 + .automatic-save = 自动保存 + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = 两次保存到 .sv$ 恢复文件之间的分钟数;0 表示关闭 (SAVETIME)。 + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = 覆盖图形时保留 .bak 副本 (ISAVEBAK) + .plotting = 打印 + .plot-device-paper-scale-and-plot-styles = 打印设备、图纸、比例和打印样式 + .plot-and-page-setup = 打印和页面设置… + .fill-truetype-glyphs-textfill = 填充 TrueType 字形 (TEXTFILL) + .command-line = 命令行 + .prompt-lines = 提示行 + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = 命令窗口上方显示的临时提示行 (CLIPROMPTLINES)。 + .history-fade-time = 历史淡出时间 + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = 历史行保持可见的时长;0 表示跳过 (COMMANDLINEFADETIME)。 + .clicking-overlapping-objects-opens-a-picker = 单击重叠对象时打开选择器 + .block-edit = 块编辑 + .double-click-to-edit-block-in-place-refedit = 双击就地编辑块 (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = 双击带属性的块以编辑属性 (ATTEDIT) + .user-preferences = 用户首选项 + .zoom = 缩放 + .reverse-mouse-wheel-zoom-zoomwheel = 反转鼠标滚轮缩放方向 (ZOOMWHEEL) + .zoom-factor = 缩放系数 + .how-far-one-wheel-notch-zooms-zoomfactor = 滚轮每格的缩放幅度 (ZOOMFACTOR)。 + .text-and-dimensions = 文字和标注 + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT 编辑一个对象后结束 (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = 连续标注继承基准标注的图层和样式 (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM 原点优先级 + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM 从哪些点测量。也可在命令内设置。 + .add-scales-automatically = 自动添加比例 + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = 哪些注释性对象接受新设置的注释比例 (ANNOAUTOSCALE)。 + .length-angle-and-insertion-units = 长度、角度和插入单位 + .drawing-units-2 = 图形单位… + .drafting = 草图 + .drafting-rotation = 草图旋转 + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = 在当前 UCS 中旋转十字光标和捕捉栅格 (SNAPANG)。 + .polar-tracking-increment = 极轴追踪增量 + .snap-and-grid = 捕捉和栅格 + .snap-modes-grid-spacing-and-object-snap = 捕捉模式、栅格间距和对象捕捉 + .drafting-settings-2 = 草图设置… + .modeling-3d = 三维建模 + .display-tools = 显示工具 + .show-the-navigation-cube-navvcube = 显示导航立方体 (NAVVCUBE) + .show-the-ucs-icon-ucsicon = 显示 UCS 图标 (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = 将 UCS 图标置于原点 (UCSICON ORigin) + .display-resolution = 显示分辨率 + .applies-to-the-current-drawing = 应用于当前图形 + .isolines-per-surface = 每个曲面的素线数 + .show-silhouette-edges-on-solids-dispsilh = 在实体上显示轮廓边 (DISPSILH) + .surface-density-u = 曲面密度 U + .surface-density-v = 曲面密度 V + .surface-type = 曲面类型 + .record-the-history-of-composite-solids-solidhist = 记录复合实体的历史 (SOLIDHIST) + .show-solid-history = 显示实体历史 + .not-available = 不可用 + .open-folder = 打开文件夹 + .where-the-application-keeps-its-own-files-these-locations-are-fixed = 应用程序存放自身文件的位置。这些位置是固定的。 + .configuration = 配置 + .plot-styles = 打印样式 + .autosave-files = 自动保存文件 .selection-modes = 选择模式 .use-shift-to-add-to-selection-pickadd = 使用 Shift 添加到选择集 (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 按住并拖动时绘制矩形而非套索 (PICKDRAG) diff --git a/locales/zh-TW/opencadstudio.ftl b/locales/zh-TW/opencadstudio.ftl index bc7a4f144..446aa9b4f 100644 --- a/locales/zh-TW/opencadstudio.ftl +++ b/locales/zh-TW/opencadstudio.ftl @@ -1375,6 +1375,71 @@ common = .pick-box-size = { common.pickbox }: { common.size } .controls-both-the-visible-selection-box-and-the-click-aperture = 同時控制可見選取框與點選範圍。 .visual-effect-settings = { common.visual-effects } · { properties.settings } + .applications = 應用程式 + .installed-plugins-and-their-sources = 已安裝的外掛程式及其來源 + .plugins-2 = 外掛程式… + .keyboard-shortcuts-2 = 鍵盤快速鍵 + .keyboard-shortcuts-3 = 鍵盤快速鍵… + .command-aliases-2 = 指令別名 + .command-aliases-3 = 指令別名… + .file-safety = 檔案安全 + .automatic-save = 自動儲存 + .minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime = 兩次儲存到 .sv$ 復原檔之間的分鐘數;0 表示關閉 (SAVETIME)。 + .keep-a-bak-copy-when-overwriting-a-drawing-isavebak = 覆寫圖面時保留 .bak 副本 (ISAVEBAK) + .plotting = 出圖 + .plot-device-paper-scale-and-plot-styles = 出圖裝置、紙張、比例與出圖樣式 + .plot-and-page-setup = 出圖與頁面設定… + .fill-truetype-glyphs-textfill = 填滿 TrueType 字符 (TEXTFILL) + .command-line = 指令行 + .prompt-lines = 提示行 + .temporary-prompt-lines-shown-above-the-command-window-clipromptlines = 指令視窗上方顯示的暫時提示行 (CLIPROMPTLINES)。 + .history-fade-time = 歷程淡出時間 + .how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime = 歷程行保持顯示的時間;0 表示略過 (COMMANDLINEFADETIME)。 + .clicking-overlapping-objects-opens-a-picker = 點選重疊的物件時開啟選擇器 + .block-edit = 圖塊編輯 + .double-click-to-edit-block-in-place-refedit = 雙擊以就地編輯圖塊 (REFEDIT) + .double-click-attributed-blocks-to-edit-attributes-attedit = 雙擊具屬性的圖塊以編輯屬性 (ATTEDIT) + .user-preferences = 使用者偏好設定 + .zoom = 縮放 + .reverse-mouse-wheel-zoom-zoomwheel = 反轉滑鼠滾輪縮放方向 (ZOOMWHEEL) + .zoom-factor = 縮放係數 + .how-far-one-wheel-notch-zooms-zoomfactor = 滾輪每一格的縮放幅度 (ZOOMFACTOR)。 + .text-and-dimensions = 文字與標註 + .textedit-edits-one-object-and-ends-texteditmode = TEXTEDIT 編輯一個物件後結束 (TEXTEDITMODE) + .continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode = 連續標註繼承基準標註的圖層與樣式 (DIMCONTINUEMODE) + .qdim-origin-priority = QDIM 原點優先順序 + .which-points-qdim-measures-from-also-settable-inside-the-command = QDIM 從哪些點量測。也可在指令內設定。 + .add-scales-automatically = 自動加入比例 + .which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale = 哪些註解性物件接受新設定的註解比例 (ANNOAUTOSCALE)。 + .length-angle-and-insertion-units = 長度、角度與插入單位 + .drawing-units-2 = 圖面單位… + .drafting = 製圖 + .drafting-rotation = 製圖旋轉 + .rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang = 在使用中的 UCS 內旋轉十字游標與鎖點格線 (SNAPANG)。 + .polar-tracking-increment = 極座標追蹤增量 + .snap-and-grid = 鎖點與格線 + .snap-modes-grid-spacing-and-object-snap = 鎖點模式、格線間距與物件鎖點 + .drafting-settings-2 = 製圖設定… + .modeling-3d = 3D 塑型 + .display-tools = 顯示工具 + .show-the-navigation-cube-navvcube = 顯示導覽方塊 (NAVVCUBE) + .show-the-ucs-icon-ucsicon = 顯示 UCS 圖示 (UCSICON) + .place-the-ucs-icon-at-the-origin-ucsicon-origin = 將 UCS 圖示置於原點 (UCSICON ORigin) + .display-resolution = 顯示解析度 + .applies-to-the-current-drawing = 套用至目前的圖面 + .isolines-per-surface = 每個曲面的等值線 + .show-silhouette-edges-on-solids-dispsilh = 在實體上顯示輪廓邊 (DISPSILH) + .surface-density-u = 曲面密度 U + .surface-density-v = 曲面密度 V + .surface-type = 曲面類型 + .record-the-history-of-composite-solids-solidhist = 記錄複合實體的歷程 (SOLIDHIST) + .show-solid-history = 顯示實體歷程 + .not-available = 無法使用 + .open-folder = 開啟資料夾 + .where-the-application-keeps-its-own-files-these-locations-are-fixed = 應用程式存放自身檔案的位置。這些位置是固定的。 + .configuration = 組態 + .plot-styles = 出圖樣式 + .autosave-files = 自動儲存檔案 .selection-modes = 選取模式 .use-shift-to-add-to-selection-pickadd = 使用 Shift 加入選取集 (PICKADD) .press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag = 按住並拖曳時繪製矩形而非套索 (PICKDRAG) diff --git a/src/locale_catalog.rs b/src/locale_catalog.rs index 1e09a7e20..700d2f68c 100644 --- a/src/locale_catalog.rs +++ b/src/locale_catalog.rs @@ -4120,6 +4120,71 @@ pub(super) fn message_attribute(source: &str) -> Option<(&'static str, &'static "Pick box size" => Some(("common", "pick-box-size")), "Controls both the visible selection box and the click aperture." => Some(("common", "controls-both-the-visible-selection-box-and-the-click-aperture")), "Visual Effect Settings" => Some(("common", "visual-effect-settings")), + "Applications" => Some(("common", "applications")), + "Installed plugins and their sources" => Some(("common", "installed-plugins-and-their-sources")), + "Plugins…" => Some(("common", "plugins-2")), + "Keyboard shortcuts" => Some(("common", "keyboard-shortcuts-2")), + "Keyboard Shortcuts…" => Some(("common", "keyboard-shortcuts-3")), + "Command aliases" => Some(("common", "command-aliases-2")), + "Command Aliases…" => Some(("common", "command-aliases-3")), + "File Safety" => Some(("common", "file-safety")), + "Automatic save" => Some(("common", "automatic-save")), + "Minutes between recovery saves to a .sv$ file; 0 turns it off (SAVETIME)." => Some(("common", "minutes-between-recovery-saves-to-a-sv-file-0-turns-it-off-savetime")), + "Keep a .bak copy when overwriting a drawing (ISAVEBAK)" => Some(("common", "keep-a-bak-copy-when-overwriting-a-drawing-isavebak")), + "Plotting" => Some(("common", "plotting")), + "Plot device, paper, scale and plot styles" => Some(("common", "plot-device-paper-scale-and-plot-styles")), + "Plot and Page Setup…" => Some(("common", "plot-and-page-setup")), + "Fill TrueType glyphs (TEXTFILL)" => Some(("common", "fill-truetype-glyphs-textfill")), + "Command Line" => Some(("common", "command-line")), + "Prompt lines" => Some(("common", "prompt-lines")), + "Temporary prompt lines shown above the command window (CLIPROMPTLINES)." => Some(("common", "temporary-prompt-lines-shown-above-the-command-window-clipromptlines")), + "History fade time" => Some(("common", "history-fade-time")), + "How long overlay history lines stay visible; 0 skips them (COMMANDLINEFADETIME)." => Some(("common", "how-long-overlay-history-lines-stay-visible-0-skips-them-commandlinefadetime")), + "Clicking overlapping objects opens a picker" => Some(("common", "clicking-overlapping-objects-opens-a-picker")), + "Block Edit" => Some(("common", "block-edit")), + "Double-click to edit block in-place (REFEDIT)" => Some(("common", "double-click-to-edit-block-in-place-refedit")), + "Double-click attributed blocks to edit attributes (ATTEDIT)" => Some(("common", "double-click-attributed-blocks-to-edit-attributes-attedit")), + "User Preferences" => Some(("common", "user-preferences")), + "Zoom" => Some(("common", "zoom")), + "Reverse mouse wheel zoom (ZOOMWHEEL)" => Some(("common", "reverse-mouse-wheel-zoom-zoomwheel")), + "Zoom factor" => Some(("common", "zoom-factor")), + "How far one wheel notch zooms (ZOOMFACTOR)." => Some(("common", "how-far-one-wheel-notch-zooms-zoomfactor")), + "Text and Dimensions" => Some(("common", "text-and-dimensions")), + "TEXTEDIT edits one object and ends (TEXTEDITMODE)" => Some(("common", "textedit-edits-one-object-and-ends-texteditmode")), + "Continued dimensions inherit the base dimension's layer and style (DIMCONTINUEMODE)" => Some(("common", "continued-dimensions-inherit-the-base-dimension-s-layer-and-style-dimcontinuemode")), + "QDIM origin priority" => Some(("common", "qdim-origin-priority")), + "Which points QDIM measures from. Also settable inside the command." => Some(("common", "which-points-qdim-measures-from-also-settable-inside-the-command")), + "Add scales automatically" => Some(("common", "add-scales-automatically")), + "Which annotative objects pick up a newly set annotation scale (ANNOAUTOSCALE)." => Some(("common", "which-annotative-objects-pick-up-a-newly-set-annotation-scale-annoautoscale")), + "Length, angle and insertion units" => Some(("common", "length-angle-and-insertion-units")), + "Drawing Units…" => Some(("common", "drawing-units-2")), + "Drafting" => Some(("common", "drafting")), + "Drafting rotation" => Some(("common", "drafting-rotation")), + "Rotates the crosshair and the snap grid in the active UCS (SNAPANG)." => Some(("common", "rotates-the-crosshair-and-the-snap-grid-in-the-active-ucs-snapang")), + "Polar tracking increment" => Some(("common", "polar-tracking-increment")), + "Snap and Grid" => Some(("common", "snap-and-grid")), + "Snap modes, grid spacing and object snap" => Some(("common", "snap-modes-grid-spacing-and-object-snap")), + "Drafting Settings…" => Some(("common", "drafting-settings-2")), + "3D Modeling" => Some(("common", "modeling-3d")), + "Display Tools" => Some(("common", "display-tools")), + "Show the navigation cube (NAVVCUBE)" => Some(("common", "show-the-navigation-cube-navvcube")), + "Show the UCS icon (UCSICON)" => Some(("common", "show-the-ucs-icon-ucsicon")), + "Place the UCS icon at the origin (UCSICON ORigin)" => Some(("common", "place-the-ucs-icon-at-the-origin-ucsicon-origin")), + "Display Resolution" => Some(("common", "display-resolution")), + "applies to the current drawing" => Some(("common", "applies-to-the-current-drawing")), + "Isolines per surface" => Some(("common", "isolines-per-surface")), + "Show silhouette edges on solids (DISPSILH)" => Some(("common", "show-silhouette-edges-on-solids-dispsilh")), + "Surface density U" => Some(("common", "surface-density-u")), + "Surface density V" => Some(("common", "surface-density-v")), + "Surface type" => Some(("common", "surface-type")), + "Record the history of composite solids (SOLIDHIST)" => Some(("common", "record-the-history-of-composite-solids-solidhist")), + "Show solid history" => Some(("common", "show-solid-history")), + "Not available" => Some(("common", "not-available")), + "Open folder" => Some(("common", "open-folder")), + "Where the application keeps its own files. These locations are fixed." => Some(("common", "where-the-application-keeps-its-own-files-these-locations-are-fixed")), + "Configuration" => Some(("common", "configuration")), + "Plot styles" => Some(("common", "plot-styles")), + "Autosave files" => Some(("common", "autosave-files")), "Selection Modes" => Some(("common", "selection-modes")), "Use Shift to add to selection (PICKADD)" => Some(("common", "use-shift-to-add-to-selection-pickadd")), "Press and drag draws a rectangle instead of a lasso (PICKDRAG)" => Some(("common", "press-and-drag-draws-a-rectangle-instead-of-a-lasso-pickdrag")), From 4cc8e7db623b326f546547671d63a162cf4b47e7 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:03:02 +0300 Subject: [PATCH 12/53] Fix four things the Options survey turned up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **TEXTFILL marked the drawing modified.** It is an application preference — it lives in a process global and is snapshotted into settings.json — but it was missing from the list of names that persist, so setting it dirtied the open drawing instead and never reached the settings file through that path. **LTSCALE and CELTSCALE validated differently depending on the door.** The standalone commands reject a value of zero or less, because a non-positive linetype scale degenerates every dash pattern in the drawing. `SETVAR LTSCALE -5` accepted it. A test drives both entry points for both variables and fails on the SETVAR path without the fix. **ISAVEBAK, SAVETIME and FILEASSOC were missing from the autocomplete registry** although all three dispatch, so the command line never offered them. **`bg_color` and `paper_bg_color` were dead fields**, always written as `None` and ignored on load since the model-space configuration superseded them. Removing them is safe: the config is `#[serde(default)]` and ignores unknown keys, so existing settings files simply stop carrying them. One thing the survey flagged that turned out not to be a bug: on the Display page the model background row hides itself outside Custom mode while the paper and desk rows do not. That asymmetry is correct — the canvas mode governs only the model background, and the other two apply in every mode. Co-Authored-By: Claude Opus 5 --- src/app/commands/mod.rs | 5 +++ src/app/commands/styleprops.rs | 56 ++++++++++++++++++++++++++++++++-- src/app/settings.rs | 8 ----- src/app/update/file.rs | 3 -- 4 files changed, 59 insertions(+), 13 deletions(-) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 8efa97d5b..3c6a00624 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -554,6 +554,11 @@ inventory::submit!(crate::command::CommandRegistration { "GRIPHOT", "GRIPHOVER", "GRIPOBJLIMIT", + // Dispatched all along, but absent from the registry, so command-line + // completion never offered them. + "ISAVEBAK", + "SAVETIME", + "FILEASSOC", // Reset selected entities' overrides to follow their layer. "SETBYLAYER", // Remove duplicate objects; set drawing base point; audit integrity; diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 12128fa88..b99da19b4 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1167,24 +1167,37 @@ impl OpenCADStudio { let outcome: Result<(String, bool), String> = { let h = &mut self.tabs[i].scene.document.header; match name.as_str() { + // The standalone LTSCALE and CELTSCALE commands + // require a positive value; reaching the same + // variable through SETVAR used to accept zero or a + // negative one, which degenerates every dash + // pattern in the drawing. "LTSCALE" => match &value { Some(v) => v .parse::() + .ok() + .filter(|x| x.is_finite() && *x > 0.0) .map(|x| { h.linetype_scale = x; (format!("LTSCALE = {x}"), true) }) - .map_err(|_| "SETVAR: numeric value required.".into()), + .ok_or_else(|| { + "SETVAR: positive numeric value required.".into() + }), None => Ok((format!("LTSCALE = {}", h.linetype_scale), false)), }, "CELTSCALE" => match &value { Some(v) => v .parse::() + .ok() + .filter(|x| x.is_finite() && *x > 0.0) .map(|x| { h.current_entity_linetype_scale = x; (format!("CELTSCALE = {x}"), true) }) - .map_err(|_| "SETVAR: numeric value required.".into()), + .ok_or_else(|| { + "SETVAR: positive numeric value required.".into() + }), None => Ok(( format!("CELTSCALE = {}", h.current_entity_linetype_scale), false, @@ -2156,6 +2169,12 @@ impl OpenCADStudio { | "GRIPHOVER" | "GRIPOBJLIMIT" | "DELOBJ" + // App-level, not part of the drawing: + // it lives in a process global and is + // snapshotted into the settings file. + // Without this it marked the drawing + // modified instead of persisting. + | "TEXTFILL" ) { self.persist_settings_if_changed(); } else { @@ -3144,3 +3163,36 @@ mod tests { assert_eq!(app.delete_objects, 3); } } + +#[cfg(test)] +mod scale_validation_tests { + use crate::app::OpenCADStudio; + + /// A linetype scale of zero or less degenerates every dash pattern in the + /// drawing, which is why the standalone commands reject it. Reaching the + /// same variable through SETVAR used to accept it, so which door you came + /// through decided whether the drawing could be broken. + #[test] + fn both_doors_to_ltscale_refuse_a_non_positive_value() { + for name in ["LTSCALE", "CELTSCALE"] { + for entry in [format!("{name} -5"), format!("SETVAR {name} -5")] { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + let before = if name == "LTSCALE" { + app.tabs[i].scene.document.header.linetype_scale + } else { + app.tabs[i].scene.document.header.current_entity_linetype_scale + }; + let _ = app.run_command_line(&entry); + let after = if name == "LTSCALE" { + app.tabs[i].scene.document.header.linetype_scale + } else { + app.tabs[i].scene.document.header.current_entity_linetype_scale + }; + assert_eq!(before, after, "{entry} must be refused"); + } + } + } +} + diff --git a/src/app/settings.rs b/src/app/settings.rs index 3032b0a60..9db2d574c 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -269,12 +269,6 @@ pub struct UserSettings { pub pick_drag_rect: bool, /// Show the floating Quick Properties panel when objects are selected. pub quick_properties: bool, - /// Persisted viewport background colours (0–255 RGB); `None` = app default - /// (dark grey model / off-white paper). Applied to every drawing tab on - /// launch and to tabs opened later, so a chosen background survives restarts - /// (#188). - pub bg_color: Option<[u8; 3]>, - pub paper_bg_color: Option<[u8; 3]>, /// Interface language preference. `System` negotiates against the /// platform locale on every launch. pub language: crate::i18n::Language, @@ -384,8 +378,6 @@ impl Default for UserSettings { pick_add: true, pick_drag_rect: false, quick_properties: false, - bg_color: None, - paper_bg_color: None, language: crate::i18n::Language::default(), cliprompt_lines: 3, commandline_fade_ms: 3000, diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 86694f6b2..a98df8247 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -435,8 +435,6 @@ impl OpenCADStudio { pick_add: self.pick_add, pick_drag_rect: self.pick_drag_rect, quick_properties: self.quick_properties, - bg_color: None, - paper_bg_color: None, language: self.language, cliprompt_lines: crate::app::settings::clamp_clipromptlines(self.cliprompt_lines), commandline_fade_ms: crate::app::settings::clamp_commandline_fade_ms( @@ -516,7 +514,6 @@ impl OpenCADStudio { self.pick_add = s.pick_add; self.pick_drag_rect = s.pick_drag_rect; self.quick_properties = s.quick_properties; - // Legacy settings.bg_color / paper_bg_color are superseded by model_space config. if crate::i18n::set_language(s.language).is_ok() { self.language = s.language; } From 3093b631137e6b1c05b4fb6a005a82a71e194d3b Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:14:00 +0300 Subject: [PATCH 13/53] Stop the test suite reading and writing the developer's own settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `new_for_test` was `Self::new()`, and `new` loads the real settings file. So every test ran against whatever the person running it had last set in the application. It surfaced as `a_selection_past_the_limit_gets_no_grips` failing on a machine where GRIPOBJLIMIT had been changed by hand: the test expected the default limit and got 32767. On a clean checkout, and in CI, it passes — which is the worst shape for this kind of bug. Tests now start from `AppConfig::default()`. That alone made things worse rather than better. The suite builds whole applications and changes preferences on them, and `AppConfig::save` writes to the real path, so a test run would have written *defaults* over the user's file where before it wrote back roughly what it had read. `save` is now inert under `cfg(test)`. Both halves matter: the first makes the suite deterministic, the second makes it harmless. This series adds around fifteen persisted settings, so every one of them was another way for a developer's own configuration to turn the suite red — or to be quietly overwritten by it. Co-Authored-By: Claude Opus 5 --- src/app/config.rs | 9 +++++++++ src/app/mod.rs | 11 ++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/src/app/config.rs b/src/app/config.rs index b22e28e70..6d1c8f0eb 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -460,7 +460,16 @@ impl AppConfig { /// Persist the config as JSON. Best-effort; silent on unavailable or /// read-only storage. + /// + /// Does nothing under `cfg(test)`. The path is the developer's own + /// settings file, and the suite builds whole applications and changes + /// preferences on them — without this, running `cargo test` rewrites the + /// settings of whoever ran it. pub fn save(&self) { + if cfg!(test) { + return; + } + #[cfg(not(target_arch = "wasm32"))] { let Some(path) = config_path() else { return }; diff --git a/src/app/mod.rs b/src/app/mod.rs index 9269c3b3d..eef45d546 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -3856,7 +3856,16 @@ impl OpenCADStudio { #[cfg(test)] pub(crate) fn new_for_test() -> Self { - Self::new() + let mut app = Self::new(); + // `new` loads the real settings file, so without this every test runs + // against whatever the developer last set in the application — a suite + // that passes on a clean machine and fails on a used one. It surfaced + // when a persisted `GRIPOBJLIMIT` made the grip-limit test see 32767 + // where it expected the default, and the number of persisted settings + // only grows. + app.apply_config(crate::app::config::AppConfig::default()); + app.last_saved_config = Some(app.current_config()); + app } /// Install `cmd` as the active interactive command for tab `tab`. From 78bbd1065aa7b14eb2c3b098025e61de31df7c70 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:42:19 +0300 Subject: [PATCH 14/53] fix(section): align live section wireframe and grips --- src/entities/extended.rs | 190 ++++++++++++++++++++++++++++++++++++--- src/scene/mod.rs | 95 ++++++++++++++++++-- 2 files changed, 270 insertions(+), 15 deletions(-) diff --git a/src/entities/extended.rs b/src/entities/extended.rs index 38fe46ddd..f79e5163b 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -20,6 +20,11 @@ use crate::scene::model::wire_model::SnapHint; const NAN: [f64; 3] = [f64::NAN; 3]; const SECTION_SLICE_APP: &str = "IsSlice"; const SECTION_THICKNESS_APP: &str = "ThicknessDepth"; +const SECTION_GRIP_LEFT: usize = 100_000; +const SECTION_GRIP_RIGHT: usize = 100_001; +const SECTION_GRIP_TOP: usize = 100_002; +const SECTION_GRIP_BOTTOM: usize = 100_003; +const SECTION_GRIP_NORMAL: usize = 100_004; pub(crate) fn section_is_slice(entity: &ExtendedEntity) -> bool { entity @@ -1563,15 +1568,43 @@ fn move_extents( fn grips(entity: &ExtendedEntity) -> Vec { match &entity.data { - ExtendedEntityData::SectionObject(data) => data - .vertices - .iter() - .chain(data.back_line_vertices.iter()) - .enumerate() - .map(|(index, point)| { - square_grip(index, glam::DVec3::new(point.x, point.y, point.z)) - }) - .collect(), + ExtendedEntityData::SectionObject(data) => { + let mut grips: Vec = if section_kind(entity, data) == "Plane" + && data.vertices.len() == 2 + && data.back_line_vertices.is_empty() + { + section_plane_edge_grips(data) + } else { + data.vertices + .iter() + .chain(data.back_line_vertices.iter()) + .enumerate() + .map(|(index, point)| { + square_grip(index, glam::DVec3::new(point.x, point.y, point.z)) + }) + .collect() + }; + if let Some(center) = section_center(data) { + let normal = section_viewing_direction(data); + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length()); + let offset = (span * 0.08).max(1.0e-6); + let normal_world = glam::DVec3::new(normal.x, normal.y, normal.z); + grips.push(GripDef { + id: SECTION_GRIP_NORMAL, + world: glam::DVec3::new(center.x, center.y, center.z) + + normal_world * offset, + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Triangle, + dir: Some(normal_world), + axis: Some(normal_world), + }); + } + grips + } ExtendedEntityData::ArcAlignedText(data) => vec![center_grip( 0, glam::DVec3::new(data.center.x, data.center.y, data.center.z), @@ -1643,7 +1676,41 @@ fn grips(entity: &ExtendedEntity) -> Vec { fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { match &mut entity.data { ExtendedEntityData::SectionObject(data) => { - if grip_id < data.vertices.len() { + if matches!( + grip_id, + SECTION_GRIP_LEFT + | SECTION_GRIP_RIGHT + | SECTION_GRIP_TOP + | SECTION_GRIP_BOTTOM + ) { + apply_section_plane_edge_grip(data, grip_id, apply); + } else if grip_id == SECTION_GRIP_NORMAL { + let Some(center) = section_center(data) else { + return; + }; + let normal = section_viewing_direction(data); + let span = data + .vertices + .first() + .zip(data.vertices.last()) + .map_or(1.0, |(first, last)| (*last - *first).length()); + let offset = (span * 0.08).max(1.0e-6); + let current = center + normal * offset; + let delta = match apply { + GripApply::Translate(delta) => Vector3::new(delta.x, delta.y, delta.z), + GripApply::Absolute(position) => { + Vector3::new(position.x, position.y, position.z) - current + } + }; + let constrained = normal * delta.dot(&normal); + for point in data + .vertices + .iter_mut() + .chain(data.back_line_vertices.iter_mut()) + { + *point = *point + constrained; + } + } else if grip_id < data.vertices.len() { apply_point(&mut data.vertices[grip_id], apply); } else if let Some(point) = data .back_line_vertices @@ -1704,6 +1771,109 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { } } +fn section_center(data: &SectionObjectData) -> Option { + let (first, last) = data.vertices.first().zip(data.vertices.last())?; + Some((*first + *last) * 0.5) +} + +fn section_plane_edge_grips(data: &SectionObjectData) -> Vec { + let Some((first, last)) = data.vertices.first().zip(data.vertices.last()) else { + return Vec::new(); + }; + let center = (*first + *last) * 0.5; + let tangent = section_tangent(data); + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let definitions = [ + ( + SECTION_GRIP_LEFT, + *first + vertical * edge_center_offset, + vertical, + tangent, + ), + ( + SECTION_GRIP_RIGHT, + *last + vertical * edge_center_offset, + vertical, + tangent, + ), + ( + SECTION_GRIP_TOP, + center + vertical * data.top_height, + tangent, + vertical, + ), + ( + SECTION_GRIP_BOTTOM, + center - vertical * data.bottom_height, + tangent, + vertical, + ), + ]; + definitions + .into_iter() + .map(|(id, world, edge, axis)| GripDef { + id, + world: glam::DVec3::new(world.x, world.y, world.z), + is_midpoint: false, + shape: crate::scene::model::object::GripShape::Rectangle, + dir: Some(glam::DVec3::new(edge.x, edge.y, edge.z)), + axis: Some(glam::DVec3::new(axis.x, axis.y, axis.z)), + }) + .collect() +} + +fn apply_section_plane_edge_grip( + data: &mut SectionObjectData, + grip_id: usize, + apply: GripApply, +) { + let Some((first, last)) = data.vertices.first().zip(data.vertices.last()) else { + return; + }; + let center = (*first + *last) * 0.5; + let tangent = section_tangent(data); + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let current = match grip_id { + SECTION_GRIP_LEFT => *first + vertical * edge_center_offset, + SECTION_GRIP_RIGHT => *last + vertical * edge_center_offset, + SECTION_GRIP_TOP => center + vertical * data.top_height, + SECTION_GRIP_BOTTOM => center - vertical * data.bottom_height, + _ => return, + }; + let delta = match apply { + GripApply::Translate(delta) => Vector3::new(delta.x, delta.y, delta.z), + GripApply::Absolute(position) => { + Vector3::new(position.x, position.y, position.z) - current + } + }; + let span = (*last - *first).length(); + let minimum_span = 1.0e-6; + match grip_id { + SECTION_GRIP_LEFT => { + let change = delta + .dot(&tangent) + .min((span - minimum_span).max(0.0)); + data.vertices[0] = data.vertices[0] + tangent * change; + } + SECTION_GRIP_RIGHT => { + let last = data.vertices.len() - 1; + let change = delta + .dot(&tangent) + .max((-span + minimum_span).min(0.0)); + data.vertices[last] = data.vertices[last] + tangent * change; + } + SECTION_GRIP_TOP => { + data.top_height = (data.top_height + delta.dot(&vertical)).max(0.0); + } + SECTION_GRIP_BOTTOM => { + data.bottom_height = (data.bottom_height - delta.dot(&vertical)).max(0.0); + } + _ => {} + } +} + fn entity_transform(transform: &EntityTransform) -> Transform { match transform { EntityTransform::Translate(delta) => { diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f136d16e3..ac87040ed 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -5410,8 +5410,12 @@ impl Scene { // changed entities into it, instead of re-cloning + re-sorting the whole // set. Falls back to the full build below when it can't (no cache entry, // journal un-replayable, or a structural assumption violated). - if let Some(arc) = - self.try_resident_patch( + // A live section changes the displayed wires of every intersected + // solid, even when the delta journal contains only the section entity. + // Rebuild the resident set in that case so an incremental patch cannot + // retain the source solid's uncut edge cache. + if self.active_live_section(block).is_none() { + if let Some(arc) = self.try_resident_patch( key, block, bg, @@ -5420,9 +5424,9 @@ impl Scene { all_visible, frozen_layers, style_viewport, - ) - { - return arc; + ) { + return arc; + } } // Build once: full tessellation, no cull, no zoom LOD — the resident // set is zoom-independent (GPU analytical circles/arcs/ellipses). @@ -6996,6 +7000,48 @@ impl Scene { Self::section_body(&body, §ion.data, section.slice_depth, None) } + /// Build a temporary entity whose wire data matches the live-section body. + /// The document entity stays unchanged so disabling the section restores + /// the complete source geometry and saving never persists the clipped body. + fn sectioned_wire_entity( + &self, + handle: Handle, + section: &LiveSection, + ) -> Result, ()> { + let Some(body) = self.sectioned_body(handle, section)? else { + return Ok(None); + }; + let (_, wires, center) = self + .prepare_solid_model_display(handle, &body) + .ok_or(())?; + let mut entity = self.document.get_entity(handle).cloned().ok_or(())?; + let reference = acadrust::types::Vector3::new(center[0], center[1], center[2]); + match &mut entity { + EntityType::Solid3D(solid) => { + solid.point_of_reference = reference; + solid.wires = wires; + solid.silhouettes.clear(); + } + EntityType::Surface(surface) => { + surface.point_of_reference = reference; + surface.wires = wires; + surface.silhouettes.clear(); + } + EntityType::Region(region) => { + region.point_of_reference = reference; + region.wires = wires; + region.silhouettes.clear(); + } + EntityType::Body(body) => { + body.point_of_reference = reference; + body.wires = wires; + body.silhouettes.clear(); + } + _ => return Err(()), + } + Ok(Some(entity)) + } + fn section_source_body( &self, handle: Handle, @@ -9709,6 +9755,45 @@ impl Scene { memo_misses = visible_count; out }; + + // Normal Wireframe renders the persisted entity wires, whereas shaded + // and 3D Wireframe modes render the temporary sectioned mesh. Replace + // only the displayed wire run with one tessellated from that same + // temporary body so every visual style shows the identical live cut. + if let Some(section) = self.active_live_section(block_handle) { + let handles: Vec = self.meshes.keys().copied().collect(); + for handle in handles { + let Some(source) = self.document.get_entity(handle) else { + continue; + }; + if !visibility_ok(source) { + continue; + } + let replacement = match self.sectioned_wire_entity(handle, §ion) { + Ok(Some(entity)) => Some(tessellate_entity( + doc, + sel, + avp, + bg, + anno, + annotation_scale_handle, + &entity, + Some(blk_ref), + view_aabb, + wpp, + paper, + )), + Ok(None) => Some(Vec::new()), + Err(()) => None, + }; + let Some(replacement) = replacement else { + continue; + }; + let name = handle.value().to_string(); + wires.retain(|wire| wire.name != name); + wires.extend(replacement); + } + } let build_ms = crate::perf::elapsed_ms(t_build); // Resolve display colours for the live background. Memo hits were From 0426df79ca0bbe7c822ae50f1588403010441e2f Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:25:33 +0300 Subject: [PATCH 15/53] fix(section): add state grip menu --- src/app/update/viewport.rs | 6 ++- src/entities/extended.rs | 88 +++++++++++++++++++++++++++++++++----- src/scene/model/object.rs | 6 +++ src/scene/pick/grip.rs | 13 ++++-- src/ui/overlay.rs | 8 ++-- 5 files changed, 104 insertions(+), 17 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index df4c7a11d..e35c6ea97 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -3049,7 +3049,11 @@ impl OpenCADStudio { return Task::none(); }; let grip_shape = self.tabs[i].selected_grips[grip_index].shape; - if grip_shape == crate::scene::model::object::GripShape::Dropdown { + if matches!( + grip_shape, + crate::scene::model::object::GripShape::Dropdown + | crate::scene::model::object::GripShape::DropdownAdjacent + ) { use crate::entities::traits::EntityTypeOps; let items = self.tabs[i] .scene diff --git a/src/entities/extended.rs b/src/entities/extended.rs index f79e5163b..55de63fe0 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -13,7 +13,7 @@ use crate::entities::common::{ use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{RenderEntity, RenderObject}; use crate::scene::model::object::{ - GripApply, GripDef, PropSection, PropValue, Property, + GripApply, GripDef, GripMenuAction, GripMenuItem, PropSection, PropValue, Property, }; use crate::scene::model::wire_model::SnapHint; @@ -25,6 +25,7 @@ const SECTION_GRIP_RIGHT: usize = 100_001; const SECTION_GRIP_TOP: usize = 100_002; const SECTION_GRIP_BOTTOM: usize = 100_003; const SECTION_GRIP_NORMAL: usize = 100_004; +const SECTION_GRIP_STATE: usize = 100_005; pub(crate) fn section_is_slice(entity: &ExtendedEntity) -> bool { entity @@ -1584,6 +1585,19 @@ fn grips(entity: &ExtendedEntity) -> Vec { }) .collect() }; + if let Some(right) = data.vertices.last() { + let vertical = normalized(data.vertical_direction, Vector3::UNIT_Z); + let edge_center_offset = (data.top_height - data.bottom_height) * 0.5; + let anchor = *right + vertical * edge_center_offset; + grips.push(GripDef { + id: SECTION_GRIP_STATE, + world: glam::DVec3::new(anchor.x, anchor.y, anchor.z), + is_midpoint: false, + shape: crate::scene::model::object::GripShape::DropdownAdjacent, + dir: None, + axis: None, + }); + } if let Some(center) = section_center(data) { let normal = section_viewing_direction(data); let span = data @@ -1789,25 +1803,25 @@ fn section_plane_edge_grips(data: &SectionObjectData) -> Vec { SECTION_GRIP_LEFT, *first + vertical * edge_center_offset, vertical, - tangent, + None, ), ( SECTION_GRIP_RIGHT, *last + vertical * edge_center_offset, vertical, - tangent, + None, ), ( SECTION_GRIP_TOP, center + vertical * data.top_height, tangent, - vertical, + Some(vertical), ), ( SECTION_GRIP_BOTTOM, center - vertical * data.bottom_height, tangent, - vertical, + Some(vertical), ), ]; definitions @@ -1818,7 +1832,7 @@ fn section_plane_edge_grips(data: &SectionObjectData) -> Vec { is_midpoint: false, shape: crate::scene::model::object::GripShape::Rectangle, dir: Some(glam::DVec3::new(edge.x, edge.y, edge.z)), - axis: Some(glam::DVec3::new(axis.x, axis.y, axis.z)), + axis: axis.map(|axis| glam::DVec3::new(axis.x, axis.y, axis.z)), }) .collect() } @@ -1850,19 +1864,25 @@ fn apply_section_plane_edge_grip( }; let span = (*last - *first).length(); let minimum_span = 1.0e-6; + let in_plane_vertical = normalized( + vertical - tangent * vertical.dot(&tangent), + vertical, + ); + let vertical_change = in_plane_vertical * delta.dot(&in_plane_vertical); match grip_id { SECTION_GRIP_LEFT => { - let change = delta + let tangent_change = delta .dot(&tangent) .min((span - minimum_span).max(0.0)); - data.vertices[0] = data.vertices[0] + tangent * change; + data.vertices[0] = data.vertices[0] + tangent * tangent_change + vertical_change; } SECTION_GRIP_RIGHT => { let last = data.vertices.len() - 1; - let change = delta + let tangent_change = delta .dot(&tangent) .max((-span + minimum_span).min(0.0)); - data.vertices[last] = data.vertices[last] + tangent * change; + data.vertices[last] = + data.vertices[last] + tangent * tangent_change + vertical_change; } SECTION_GRIP_TOP => { data.top_height = (data.top_height + delta.dot(&vertical)).max(0.0); @@ -2113,6 +2133,54 @@ impl Grippable for ExtendedEntity { fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } + + fn grip_menu(&self, grip_id: usize) -> Vec { + if grip_id != SECTION_GRIP_STATE { + return Vec::new(); + } + let ExtendedEntityData::SectionObject(data) = &self.data else { + return Vec::new(); + }; + let current = section_kind(self, data); + vec![ + GripMenuItem { + label: if current == "Plane" { "✓ Plane" } else { "Plane" }, + action: GripMenuAction::SectionPlane, + }, + GripMenuItem { + label: if current == "Slice" { "✓ Slice" } else { "Slice" }, + action: GripMenuAction::SectionSlice, + }, + GripMenuItem { + label: if current == "Boundary" { + "✓ Boundary" + } else { + "Boundary" + }, + action: GripMenuAction::SectionBoundary, + }, + GripMenuItem { + label: if current == "Volume" { "✓ Volume" } else { "Volume" }, + action: GripMenuAction::SectionVolume, + }, + ] + } + + fn apply_grip_menu(&mut self, grip_id: usize, action: GripMenuAction) { + if grip_id != SECTION_GRIP_STATE + || !matches!(self.data, ExtendedEntityData::SectionObject(_)) + { + return; + } + let value = match action { + GripMenuAction::SectionPlane => "Plane", + GripMenuAction::SectionSlice => "Slice", + GripMenuAction::SectionBoundary => "Boundary", + GripMenuAction::SectionVolume => "Volume", + _ => return, + }; + apply_section_prop(self, "ext_section_state", value); + } } impl PropertyEditable for ExtendedEntity { diff --git a/src/scene/model/object.rs b/src/scene/model/object.rs index 2d0eee684..f24423b91 100644 --- a/src/scene/model/object.rs +++ b/src/scene/model/object.rs @@ -98,6 +98,8 @@ pub enum GripShape { Circle, /// Screen-offset menu selector. Dropdown, + /// Menu selector placed immediately beside its anchor grip. + DropdownAdjacent, } /// Describes one grip point for an entity. @@ -180,6 +182,10 @@ pub enum GripMenuAction { RefineVertices, ShowFit, ShowControlVertices, + SectionPlane, + SectionSlice, + SectionBoundary, + SectionVolume, MoveWithText, StackText, UnstackText, diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index e9cdb1d25..e8ff3e72f 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -13,11 +13,18 @@ pub const GRIP_HALF_PX: f32 = 5.0; /// Screen offset for dropdown selectors anchored to a vertex. pub const GRIP_DROPDOWN_OFFSET_X_PX: f32 = 24.0; pub const GRIP_DROPDOWN_OFFSET_Y_PX: f32 = 27.0; +pub const GRIP_ADJACENT_DROPDOWN_OFFSET_X_PX: f32 = 12.0; fn marker_screen_position(mut point: Point, shape: GripShape) -> Point { - if shape == GripShape::Dropdown { - point.x += GRIP_DROPDOWN_OFFSET_X_PX; - point.y += GRIP_DROPDOWN_OFFSET_Y_PX; + match shape { + GripShape::Dropdown => { + point.x += GRIP_DROPDOWN_OFFSET_X_PX; + point.y += GRIP_DROPDOWN_OFFSET_Y_PX; + } + GripShape::DropdownAdjacent => { + point.x += GRIP_ADJACENT_DROPDOWN_OFFSET_X_PX; + } + _ => {} } point } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 19b7af2a4..3a47c1732 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -781,7 +781,7 @@ fn draw_grip_marker( b.close(); }), GripShape::Circle => canvas::Path::circle(Point::new(sp.x, sp.y), h), - GripShape::Dropdown => canvas::Path::new(|b| { + GripShape::Dropdown | GripShape::DropdownAdjacent => canvas::Path::new(|b| { b.move_to(Point::new(sp.x - h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x + h, sp.y - h * 0.5)); b.line_to(Point::new(sp.x, sp.y + h)); @@ -831,7 +831,10 @@ fn draw_grip_marker( } else { palette.primary.base.color }; - let fill = if grip.shape == GripShape::Dropdown { + let fill = if matches!( + grip.shape, + GripShape::Dropdown | GripShape::DropdownAdjacent + ) { color } else { palette.background.base.color.scale_alpha(0.7) @@ -3797,4 +3800,3 @@ mod selection_visual_color_tests { assert!((selection_fill_alpha(100.0, true) - 0.45).abs() < 1e-5); } } - From 056a0c527d36f388141bf3411884146a2cacd8a6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:38:28 +0300 Subject: [PATCH 16/53] fix(section): place normal grip above plane --- src/entities/extended.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/entities/extended.rs b/src/entities/extended.rs index 55de63fe0..f82a5e261 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -1610,10 +1610,10 @@ fn grips(entity: &ExtendedEntity) -> Vec { grips.push(GripDef { id: SECTION_GRIP_NORMAL, world: glam::DVec3::new(center.x, center.y, center.z) - + normal_world * offset, + - normal_world * offset, is_midpoint: false, shape: crate::scene::model::object::GripShape::Triangle, - dir: Some(normal_world), + dir: Some(-normal_world), axis: Some(normal_world), }); } @@ -1709,7 +1709,7 @@ fn apply_grip(entity: &mut ExtendedEntity, grip_id: usize, apply: GripApply) { .zip(data.vertices.last()) .map_or(1.0, |(first, last)| (*last - *first).length()); let offset = (span * 0.08).max(1.0e-6); - let current = center + normal * offset; + let current = center - normal * offset; let delta = match apply { GripApply::Translate(delta) => Vector3::new(delta.x, delta.y, delta.z), GripApply::Absolute(position) => { From 8bf5eac3fc9efe8c362619f0e0d78e7d37d03a7c Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:43:15 +0200 Subject: [PATCH 17/53] ocs_doc_api: layer CRUD, per-curve layer assignment, XDATA/XRECORD support, and bulk SetXDataMany --- crates/ocs_doc_api/ARCHITECTURE.md | 26 +- crates/ocs_doc_api/Cargo.toml | 2 + crates/ocs_doc_api/README.md | 98 ++ crates/ocs_doc_api/bindings/README.md | 37 +- .../ocs_doc_api/bindings/python/generate.py | 18 + crates/ocs_doc_api/build.rs | 22 + crates/ocs_doc_api/examples/common/mod.rs | 284 ++++- .../examples/e5_failure_and_guards.rs | 5 +- crates/ocs_doc_api/spec/entities.toml | 80 ++ crates/ocs_doc_api/src/backend.rs | 85 +- crates/ocs_doc_api/src/convert.rs | 15 +- crates/ocs_doc_api/src/executor.rs | 140 ++- crates/ocs_doc_api/src/facade.rs | 355 +++++- crates/ocs_doc_api/src/gen/api_reference.md | 60 + .../ocs_doc_api/src/gen/binding_schema.json | 178 ++- crates/ocs_doc_api/src/gen/layouts.json | 16 +- crates/ocs_doc_api/src/gen/ops_gen.rs | 27 +- crates/ocs_doc_api/src/gen/query_gen.rs | 31 + crates/ocs_doc_api/src/layer.rs | 42 + crates/ocs_doc_api/src/lib.rs | 13 +- crates/ocs_doc_api/src/ops.rs | 193 +++ crates/ocs_doc_api/src/query.rs | 23 + crates/ocs_doc_api/src/transport/ipc.rs | 143 ++- crates/ocs_doc_api/src/validation.rs | 20 +- crates/ocs_doc_api/tests/mock_backend.rs | 486 +++++++- .../tests/spec_wire_consistency.rs | 66 +- src/app/doc_api.rs | 1056 +++++++++++++++-- 27 files changed, 3340 insertions(+), 181 deletions(-) create mode 100644 crates/ocs_doc_api/src/layer.rs diff --git a/crates/ocs_doc_api/ARCHITECTURE.md b/crates/ocs_doc_api/ARCHITECTURE.md index 7d4a34f73..8603f8a10 100644 --- a/crates/ocs_doc_api/ARCHITECTURE.md +++ b/crates/ocs_doc_api/ARCHITECTURE.md @@ -4,6 +4,12 @@ `Transport::apply` carries one operation or a read-only query batch to the executor. The native adapter binds each request to its existing `HostSession`. +For out-of-process / worker-thread plugins, `doc_api_for_host(host)` constructs a +`DocApi` backed by `OcsPluginApiIpc`, which sends `PluginRequest::DocApiRequest` +through the host's `PluginRequestSender` and deserializes +`PluginResponse::DocApiResponse`. This keeps plugin code free of manual envelope +serialization while reusing the same executor that the in-process transport uses. + The executor validates inputs and dispatches through `DocApiBackend`. Backends must prepare fallible geometry and serialization before changing the document. Bulk writes prepare all entities, begin one undo record, apply the prepared @@ -22,14 +28,18 @@ do not reorder them. The consistency tests verify their bincode discriminants. generates the API reference and binding schema. The layout snapshot is curated vocabulary, not a reflected binary codec schema. +Layer CRUD is a document-table family, not an entity family. The API carries +layer properties in the plain-data `LayerInfo` DTO and the host translates them +to/from `acadrust::tables::Layer`. Layer names are case-insensitive. The host +rejects duplicate names, deletion of layer "0" or the current layer, and removal +of a layer that still has entities assigned. + +XDATA and XRECORD are serialization roundtrip families: the API carries their +payloads as plain-data DTOs and the host stores/retrieves them without semantic +interpretation. XDATA records are keyed by registered application name on any +entity; XRECORD objects are standalone named dictionaries of group-code/value +entries. Both preserve exact bytes/values across save and reload. + To extend the API, update the DTO, backend, executor, facade and spec together, add a native behavior test, and rebuild the generated reference/schema. New backend methods must reject unsupported behavior explicitly. - -Extension workflow: - -1. Add the operation/query DTO and its name mapping, then update `spec/entities.toml`. -2. Implement validation, backend preparation, executor dispatch and facade methods. -3. Preserve each profile's plane and use cadkernel for geometry. -4. Test native behavior, failed-operation atomicity and the binding transport payload. -5. Run `cargo test -p ocs_doc_api --all-features` and rebuild the generated snapshots. diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 592effe98..f60f46a86 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -34,6 +34,8 @@ default = [] host = ["acadrust", "cadkernel"] # ipc: pull ocs_plugin_api for the IPC transport adapter (plugin side). ipc = ["acadrust", "ocs_plugin_api"] +# doc_api_host: convenience helper that builds a DocApi from a HostApi (IPC-only). +doc_api_host = ["ipc"] [lib] name = "ocs_doc_api" diff --git a/crates/ocs_doc_api/README.md b/crates/ocs_doc_api/README.md index bd1f0289a..cc38a56ca 100644 --- a/crates/ocs_doc_api/README.md +++ b/crates/ocs_doc_api/README.md @@ -30,11 +30,109 @@ Transports bind to one document tab. Typed handles from another transport and requests for another tab are rejected. Raw `ObjectId` values are relative to the receiving document. Errors are serialized as `ApiError` variants over IPC. +Layer CRUD is exposed through `Document::layers()`, `create_layer(info)`, +`update_layer(name, info)`, `delete_layer(name)` and through the `layer()` / +`set_layer(...)` methods on every entity handle (`Line`, `Circle`, `Solid`, +`Entity`, etc.). Layer properties are carried as the plain-data `LayerInfo` DTO +with `LayerFlags`, `Color` and `LineWeight`. The host enforces CAD-style rules: +layer "0" and the current layer cannot be deleted, duplicate names are rejected, +and a layer still referenced by entities cannot be removed. + +XDATA and XRECORD payloads are carried as plain-data, serde-compatible DTOs +(`XDataRecord`, `XRecordSpec`, and their value/entry types) and roundtripped +verbatim by the host. Every `Entity` handle can read/write one XDATA record per +registered application name via `xdata(application_name)` and `set_xdata(...)`, +and standalone XRECORD objects can be created with `doc.entities().create_xrecord(...)` +and updated through the `XRecord` handle. + | Feature | Use | |---|---| | Default | DTOs, facade, transport trait and binding schema | | `host` | Kernel and entity adapters, executor, `DocApiBackend`, `InProcess` | | `ipc` | `OcsPluginApiIpc` adapter for a plugin connected to the host | +| `doc_api_host` | Convenience `doc_api_for_host(&dyn HostApi) -> Option` for out-of-process / worker-thread plugins | + +### Connecting from a plugin + +For out-of-process or worker-thread plugins that receive `&dyn HostApi` (or any +`ocs_plugin_api::host::HostApi` implementor), enable the `doc_api_host` feature +and use the helper instead of manually serializing `DocApiEnvelope`s: + +```rust +use ocs_doc_api::doc_api_for_host; +use ocs_plugin_api::host::HostApi; + +fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { + let doc_api = doc_api_for_host(host)?; + let doc = doc_api.document(host.tab_id()); + let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; + Some(()) +} +``` + +`doc_api_for_host` returns `None` for hosts that do not expose a +`PluginRequestSender` (for example the in-process `HostSession`). In-process code +should use `DocApi::in_process(backend, tab_id)` with a concrete +`DocApiBackend`. + +### Plugin example: create, read, update and delete a line + +```rust +use ocs_doc_api::{doc_api_for_host, ApiResult, Color, LayerInfo, LineWeight}; +use ocs_plugin_api::host::HostApi; + +fn draw_and_modify_line(host: &mut dyn HostApi) -> ApiResult<()> { + // Build a DocApi from the host's plugin request channel. + let api = doc_api_for_host(host).ok_or_else(|| { + ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) + })?; + let doc = api.document(api.active_tab()); + + // Ensure a target layer exists. + let mut info = LayerInfo::new("MyLayer"); + info.color = Color::Index(1); + info.line_weight = LineWeight::Value(25); + let _ = doc.create_layer(&info); // ok if it already exists + + // Create a line on that layer. + let line = doc.curves().create_line([0.0, 0.0, 0.0], [10.0, 0.0, 0.0])?; + line.set_layer("MyLayer")?; + + // Read it back. + assert_eq!(line.layer()?, "MYLAYER"); + + // Move the endpoint in place (same ObjectId). + line.transform(ocs_doc_api::PlacementSpec::at([5.0, 0.0, 0.0]))?; + + // Delete it; the document is left unchanged if this failed. + line.delete()?; + Ok(()) +} +``` + +### Plugin example: create a box and read its volume + +```rust +use ocs_doc_api::{doc_api_for_host, ApiResult}; +use ocs_plugin_api::host::HostApi; + +fn create_box_and_measure(host: &mut dyn HostApi) -> ApiResult { + let api = doc_api_for_host(host).ok_or_else(|| { + ocs_doc_api::ApiError::Transport("host does not expose a DocApi channel".into()) + })?; + let doc = api.document(api.active_tab()); + + let box_ = doc.solids().create_cuboid([0.0; 3], [10.0; 3])?; + let volume = box_.volume()?; + + // Boolean example: intersect with a second box. + let other = doc.solids().create_cuboid([5.0; 3], [10.0; 3])?; + let intersection = box_.intersect(&other)?; + let _intersection_volume = intersection.volume()?; + + Ok(volume) +} +``` See the [API reference](src/gen/api_reference.md) for constructors and methods, [architecture](ARCHITECTURE.md) for backend rules, and diff --git a/crates/ocs_doc_api/bindings/README.md b/crates/ocs_doc_api/bindings/README.md index 13f351c75..deb13c55d 100644 --- a/crates/ocs_doc_api/bindings/README.md +++ b/crates/ocs_doc_api/bindings/README.md @@ -21,8 +21,39 @@ The bridge converts these objects to the Rust DTOs and bincode 1.3 wire format, sends `DocApiRequest { tab_id, bytes }`, and decodes `Result`. Host plugin API version 6 and document envelope version 1 are required. +For Rust plugins compiled against `ocs_doc_api` with the `doc_api_host` feature, +use the `doc_api_for_host` helper to build a typed `DocApi` from any +`ocs_plugin_api::host::HostApi` that exposes a `PluginRequestSender` +(out-of-process / worker-thread hosts): + +```rust +use ocs_doc_api::doc_api_for_host; +use ocs_plugin_api::host::HostApi; + +fn on_dispatch(host: &mut dyn HostApi) -> Option<()> { + let doc_api = doc_api_for_host(host)?; + let doc = doc_api.document(host.tab_id()); + let cube = doc.solids().create_cuboid([0.0; 3], [10.0; 3]).ok()?; + // ... + Some(()) +} +``` + +In-process plugins should use `DocApi::in_process(backend, tab_id)` with a +concrete `DocApiBackend`; `doc_api_for_host` returns `None` for hosts that do not +expose a `PluginRequestSender`. + Decode receipt outcomes as `{"NewId": 42}` or `{"NewIds": [42, 43]}` and query results as externally tagged dictionaries such as `{"Volume": 12.5}`. The facade -unwraps these into typed handles or values. The bridge must raise an exception -for an error response. Transport binding determines the tab; requesting another -tab or combining typed handles from different documents raises an error. +unwraps these into typed handles or values. Layer results use `{"Layers": [...]}` +and `{"EntityLayer": "WALLS"}`. Layer properties (`LayerInfo`, `LayerFlags`, +`Color`, `LineWeight`) appear as plain dictionaries in operation payloads, e.g. +`{"CreateLayer": {"name": "Walls", "flags": {"frozen": false, "locked": false, +"frozen_in_new_viewport": false, "off": false}, "color": {"Index": 7}, +"line_type": "Continuous", "line_weight": {"Default": null}, +"plot_style": "", "is_plottable": true}}`. +XDATA and XRECORD payloads appear as nested externally tagged dictionaries +(e.g. `{"XData": {"application_name": ..., "values": [...]}}` or +`{"XRecord": {"name": ..., "entries": [...]}}`). The bridge must raise an +exception for an error response. Transport binding determines the tab; requesting +another tab or combining typed handles from different documents raises an error. diff --git a/crates/ocs_doc_api/bindings/python/generate.py b/crates/ocs_doc_api/bindings/python/generate.py index 53259fb15..d361493ba 100644 --- a/crates/ocs_doc_api/bindings/python/generate.py +++ b/crates/ocs_doc_api/bindings/python/generate.py @@ -36,6 +36,12 @@ class Entity: def delete(self): self._doc._apply_op({"Delete": {"id": self.id}}) + + def layer(self): + return self._doc._apply_query({"GetEntityLayer": {"id": self.id}}) + + def set_layer(self, layer): + self._doc._apply_op({"SetEntityLayer": {"id": self.id, "layer": layer}}) ''' DOCUMENT = ''' @@ -79,6 +85,18 @@ class Document: def curves(self): return Curves(self) def entities(self): return Entities(self) + def layers(self): + return self._apply_query({"ListLayers": {}}) + + def create_layer(self, info): + self._apply_op({"CreateLayer": info}) + + def update_layer(self, name, info): + self._apply_op({"UpdateLayer": {"name": name, "info": info}}) + + def delete_layer(self, name): + self._apply_op({"DeleteLayer": {"name": name}}) + @dataclass class DocApi: _transport: Any diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs index 74c522413..67fab317a 100644 --- a/crates/ocs_doc_api/build.rs +++ b/crates/ocs_doc_api/build.rs @@ -168,10 +168,32 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, - **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: solids only.** - **`delete()`** — remove the entity (one undo step). +- **`layer()` -> `String`** — the name of the layer the entity is on. +- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; + blocked if the entity or target layer is locked). `Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed downcast when `kind == "Solid3D"`). +## Layer table + +Layers are a named document table, not an entity family. `Document::layers()` +lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, +`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` +returns the layer name and `Entity::set_layer(layer)` moves the entity to an +existing layer. The host rejects duplicate names, deletion of layer "0" or the +current layer, and removing a layer that still has entities assigned. + +## Transports + +- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. +- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the + `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes + `DocApiRequest { tab_id, bytes }` to the same executor. +- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that + builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a + `PluginRequestSender` (out-of-process / worker-thread plugins). + ## Collections & cross-cutting - **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. diff --git a/crates/ocs_doc_api/examples/common/mod.rs b/crates/ocs_doc_api/examples/common/mod.rs index 363841d44..8b7b7454b 100644 --- a/crates/ocs_doc_api/examples/common/mod.rs +++ b/crates/ocs_doc_api/examples/common/mod.rs @@ -8,11 +8,12 @@ use std::collections::HashMap; use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - ObjectId, PlacementSpec, + normalize_layer_name, Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, + GeometryRevision, LayerInfo, ObjectId, PlacementSpec, }; -#[derive(Default, Clone)] + +#[derive(Clone)] pub struct MockBackend { next_id: u64, revision: u64, @@ -31,6 +32,38 @@ pub struct MockBackend { attributes_store: HashMap>, /// Stored viewport views (target, height). viewport_views: HashMap, + /// Stored XDATA records keyed by (entity id, application name). + xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, + /// Stored XRECORD payloads keyed by entity id. + xrecord_store: HashMap, + /// Stored layer table. + layers: HashMap, + /// Layer name assigned to each entity. + entity_layers: HashMap, +} + +impl Default for MockBackend { + fn default() -> Self { + let mut backend = Self { + next_id: 0, + revision: 0, + undo_steps: 0, + bodies: HashMap::new(), + kinds: HashMap::new(), + curves: HashMap::new(), + text_values: HashMap::new(), + hatch_boundaries: HashMap::new(), + dimension_measurements: HashMap::new(), + attributes_store: HashMap::new(), + viewport_views: HashMap::new(), + xdata_store: HashMap::new(), + xrecord_store: HashMap::new(), + layers: HashMap::new(), + entity_layers: HashMap::new(), + }; + backend.layers.insert("0".to_string(), LayerInfo::new("0")); + backend + } } impl MockBackend { @@ -52,7 +85,7 @@ impl MockBackend { /// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds). fn curve_bounds(spec: &Curve2Spec) -> Aabb { match spec { - Curve2Spec::Line { start, end } => Aabb { + Curve2Spec::Line { start, end, .. } => Aabb { min: [ start[0].min(end[0]), start[1].min(end[1]), @@ -64,11 +97,11 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { start[2].max(end[2]), ], }, - Curve2Spec::Circle { centre, radius } => Aabb { + Curve2Spec::Circle { centre, radius, .. } => Aabb { min: [centre[0] - radius, centre[1] - radius, centre[2]], max: [centre[0] + radius, centre[1] + radius, centre[2]], }, - Curve2Spec::Point { position } => Aabb { + Curve2Spec::Point { position, .. } => Aabb { min: *position, max: *position, }, @@ -133,6 +166,15 @@ impl DocApiBackend for MockBackend { Ok(()) } fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { + // Validate per-spec layers up front so the op is atomic. + for spec in specs { + if let Some(name) = spec.layer() { + self.lookup_layer(name).map_err(|e| ApiError::validation( + "CreateMany", + e.to_string(), + ))?; + } + } let mut next = self.clone(); let ids = specs .iter() @@ -173,10 +215,29 @@ impl DocApiBackend for MockBackend { Curve2Spec::Ray { .. } => "Ray", Curve2Spec::XLine { .. } => "XLine", }; + let layer = spec + .layer() + .map(|name| { + self.lookup_layer(name).map(|info| info.normalized_name()) + }) + .transpose()?; let id = self.alloc(kind); self.curves.insert(id, spec.clone()); + self.entity_layers + .insert(id, layer.unwrap_or_else(|| "0".to_string())); Ok(id) } + + fn lookup_layer(&self, name: &str) -> ApiResult { + let norm = normalize_layer_name(name); + if norm.is_empty() { + return Err(ApiError::validation("LookupLayer", "empty layer name")); + } + self.layers + .get(&norm) + .cloned() + .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) + } fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { if spec.block_name.is_empty() { return Err(ApiError::validation("CreateInsert", "empty block name")); @@ -355,6 +416,144 @@ impl DocApiBackend for MockBackend { .copied() .ok_or(ApiError::UnknownId(id)) } + fn set_xdata( + &mut self, + id: ObjectId, + application_name: &str, + record: Option<&ocs_doc_api::XDataRecord>, + ) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + let key = (id, application_name.to_string()); + match record { + Some(r) => { + self.xdata_store.insert(key, r.clone()); + } + None => { + self.xdata_store.remove(&key); + } + } + Ok(()) + } + fn xdata( + &self, + id: ObjectId, + application_name: &str, + ) -> ApiResult> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + Ok(self + .xdata_store + .get(&(id, application_name.to_string())) + .cloned()) + } + fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { + let id = self.alloc("XRecord"); + self.xrecord_store.insert(id, spec.clone()); + Ok(id) + } + fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { + if !self.object_exists(id) { + return Err(ApiError::validation( + "SetXRecord", + format!("unknown ObjectId {id:?}"), + )); + } + self.xrecord_store.insert(id, spec.clone()); + Ok(()) + } + fn xrecord(&self, id: ObjectId) -> ApiResult> { + if !self.object_exists(id) { + return Err(ApiError::validation( + "GetXRecord", + format!("unknown ObjectId {id:?}"), + )); + } + Ok(self.xrecord_store.get(&id).cloned()) + } + fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { + let name = normalize_layer_name(&info.name); + if name.is_empty() { + return Err(ApiError::validation("CreateLayer", "empty layer name")); + } + if self.layers.contains_key(&name) { + return Err(ApiError::validation( + "CreateLayer", + format!("layer '{name}' already exists"), + )); + } + let mut stored = info.clone(); + stored.name = name.clone(); + self.layers.insert(name, stored); + Ok(()) + } + fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { + let name_upper = normalize_layer_name(name); + if !self.layers.contains_key(&name_upper) { + return Err(ApiError::validation( + "UpdateLayer", + format!("layer '{name}' does not exist"), + )); + } + let mut stored = info.clone(); + stored.name = name_upper.clone(); + self.layers.insert(name_upper, stored); + Ok(()) + } + fn delete_layer(&mut self, name: &str) -> ApiResult<()> { + let name_upper = normalize_layer_name(name); + if name_upper == "0" { + return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); + } + if self.entity_layers.values().any(|l| normalize_layer_name(l) == name_upper) { + return Err(ApiError::validation( + "DeleteLayer", + format!("layer '{name_upper}' still in use"), + )); + } + if self.layers.remove(&name_upper).is_none() { + return Err(ApiError::validation( + "DeleteLayer", + format!("layer '{name}' does not exist"), + )); + } + Ok(()) + } + fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + let layer_upper = normalize_layer_name(layer); + let target = self + .layers + .get(&layer_upper) + .ok_or_else(|| { + ApiError::validation( + "SetEntityLayer", + format!("layer '{layer_upper}' does not exist"), + ) + })?; + if target.flags.locked { + return Err(ApiError::Unsupported(format!( + "entity {id:?} cannot be moved to locked layer '{layer_upper}'" + ))); + } + self.entity_layers.insert(id, layer_upper); + Ok(()) + } + fn layers(&self) -> ApiResult> { + let mut v: Vec<_> = self.layers.values().cloned().collect(); + v.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(v) + } + fn entity_layer(&self, id: ObjectId) -> ApiResult { + self.entity_layers + .get(&id) + .cloned() + .ok_or(ApiError::UnknownId(id)) + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) @@ -364,17 +563,85 @@ impl DocApiBackend for MockBackend { } fn remove_entity(&mut self, id: ObjectId) -> ApiResult { self.bodies.remove(&id); + self.xdata_store.retain(|(k, _), _| *k != id); + self.xrecord_store.remove(&id); + self.entity_layers.remove(&id); Ok(self.kinds.remove(&id).is_some()) } + fn enumerate_entities( + &self, + kind: Option<&str>, + layer: Option<&str>, + include_bounds: bool, + ) -> ApiResult> { + let mut out = Vec::new(); + for (id, kind_name) in &self.kinds { + if let Some(k) = kind { + if !k.eq_ignore_ascii_case(kind_name) { + continue; + } + } + let entity_layer = self + .entity_layers + .get(id) + .cloned() + .unwrap_or_else(|| "0".to_string()); + if let Some(l) = layer { + if !l.eq_ignore_ascii_case(&entity_layer) { + continue; + } + } + let bounds = if include_bounds { + self.curves.get(id).map(curve_bounds) + } else { + None + }; + out.push(EntityView { + id: *id, + kind: kind_name.clone(), + layer: entity_layer, + bounds, + }); + } + Ok(out) + } + fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { + let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; + match spec { + Curve2Spec::Point { position, .. } => Ok(*position), + _ => Err(ApiError::Unsupported( + "GetPointPosition is only for Point entities".into(), + )), + } + } + fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { + let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; + match spec { + Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), + _ => Err(ApiError::Unsupported( + "GetLineGeometry is only for Line entities".into(), + )), + } + } fn get_entity(&mut self, id: ObjectId) -> ApiResult { let kind = self.kinds.get(&id).ok_or(ApiError::UnknownId(id))?.clone(); + let layer = self + .entity_layers + .get(&id) + .cloned() + .unwrap_or_else(|| "0".to_string()); let bounds = self.bodies.get(&id).and_then(|b| { cadkernel::brep::body_bounds(b).map(|bb| Aabb { min: bb.min, max: bb.max, }) }); - Ok(EntityView { id, kind, bounds }) + Ok(EntityView { + id, + kind, + layer, + bounds, + }) } fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { if !self.entity_exists(id) { @@ -423,6 +690,9 @@ impl DocApiBackend for MockBackend { fn entity_exists(&self, id: ObjectId) -> bool { self.kinds.contains_key(&id) } + fn object_exists(&self, id: ObjectId) -> bool { + self.xrecord_store.contains_key(&id) + } fn revision(&self) -> GeometryRevision { GeometryRevision(self.revision) } diff --git a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs index f86db5132..2cc82dbe5 100644 --- a/crates/ocs_doc_api/examples/e5_failure_and_guards.rs +++ b/crates/ocs_doc_api/examples/e5_failure_and_guards.rs @@ -54,10 +54,7 @@ fn main() -> ocs_doc_api::ApiResult<()> { } // A totally unknown id also surfaces a structured error. - assert!(matches!( - doc.entities().get(ObjectId::from_u64(0xDEAD)), - Err(_) - )); + assert!(doc.entities().get(ObjectId::from_u64(0xDEAD)).is_err()); println!("failure semantics verified"); Ok(()) } diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index b885693dd..e490f7d7b 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -457,6 +457,50 @@ name = "create_table" args = [{ name = "insertion_point", ty = "[f64; 3]" }, { name = "data", ty = "&[Vec]" }] op = "CreateTable" +# ============================================================================ +# XDATA / XRECORD FAMILY — application-specific extended data roundtrip +# ============================================================================ +[[family]] +name = "XRecord" +acadrust_variant = "XRecord" +handle = "XRecord" +collection = "entities" +description = "Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload." + +[[family.constructor]] +name = "create_xrecord" +args = [{ name = "spec", ty = "XRecordSpec" }] +op = "CreateXRecord" +returns = "Entity" + +[[family.method]] +name = "payload" +kind = "query" +query = "GetXRecord" +returns = "XRecordSpec" + +[[family.method]] +name = "set_payload" +kind = "op" +op = "SetXRecord" +args = [{ name = "spec", ty = "XRecordSpec" }] +returns = "()" + +# Cross-cutting: every Entity handle can read/write its own XDATA. +[[family.method]] +name = "xdata" +kind = "query" +query = "GetXData" +args = [{ name = "application_name", ty = "&str" }] +returns = "Option" + +[[family.method]] +name = "set_xdata" +kind = "op" +op = "SetXData" +args = [{ name = "application_name", ty = "&str" }, { name = "record", ty = "Option" }] +returns = "()" + # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ @@ -469,6 +513,42 @@ op = "CreateTable" # - Read-only batching: query_batch(|q| ...) — one round-trip, no revision bump. # - OpGroup: best-effort client-side failure cleanup (NOT a transaction). +# ============================================================================ +# LAYER family (named drawing layers) +# ============================================================================ +[[family]] +name = "Layer" +handle = "Layer" +collection = "layers" +description = "Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers()." + +[[family.method]] +name = "list" +kind = "query" +query = "ListLayers" +returns = "Vec" + +[[family.method]] +name = "create" +kind = "op" +op = "CreateLayer" +args = [{ name = "info", ty = "LayerInfo" }] +returns = "()" + +[[family.method]] +name = "update" +kind = "op" +op = "UpdateLayer" +args = [{ name = "name", ty = "String" }, { name = "info", ty = "LayerInfo" }] +returns = "()" + +[[family.method]] +name = "delete" +kind = "op" +op = "DeleteLayer" +args = [{ name = "name", ty = "String" }] +returns = "()" + # ---- Kernel/op mapping (reference for the executor + docs) ---- [[kernel_map]] op = "CreateSolid.Cuboid" diff --git a/crates/ocs_doc_api/src/backend.rs b/crates/ocs_doc_api/src/backend.rs index 5a857db8a..51e4cfafe 100644 --- a/crates/ocs_doc_api/src/backend.rs +++ b/crates/ocs_doc_api/src/backend.rs @@ -5,7 +5,7 @@ use crate::error::{ApiError, ApiResult}; use crate::id::ObjectId; -use crate::ops::Curve2Spec; +use crate::ops::{Curve2Spec, LayerInfo}; use crate::query::{Aabb, EntityView}; use crate::revision::GeometryRevision; @@ -152,6 +152,85 @@ pub trait DocApiBackend { sections: &[(cadkernel::space::Plane, Vec)], ) -> ApiResult; + /// Attach or replace an XDATA record for `application_name` on `id`. + /// `None` removes any existing record for that application. + fn set_xdata( + &mut self, + id: ObjectId, + application_name: &str, + record: Option<&crate::ops::XDataRecord>, + ) -> ApiResult<()>; + + /// Read the XDATA record for `application_name` on `id` (`None` if absent). + fn xdata( + &self, + id: ObjectId, + application_name: &str, + ) -> ApiResult>; + + /// Create a standalone `XRECORD` object from a spec; returns the fresh `ObjectId`. + fn add_xrecord(&mut self, spec: &crate::ops::XRecordSpec) -> ApiResult; + + /// Replace an existing `XRECORD` object's payload in place. + fn set_xrecord(&mut self, id: ObjectId, spec: &crate::ops::XRecordSpec) -> ApiResult<()>; + + /// Read an existing `XRECORD` object's payload (`None` if `id` is not an XRecord). + fn xrecord(&self, id: ObjectId) -> ApiResult>; + + // ── layer CRUD ───────────────────────────────────────────────────────── + /// Create a new layer. Fails with `Validation` if the name already exists or is reserved. + fn create_layer(&mut self, info: &crate::ops::LayerInfo) -> ApiResult<()>; + + /// Update an existing layer's properties by name. Fails with `Validation` if absent. + fn update_layer(&mut self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()>; + + /// Delete a layer by name. Fails with `Validation` if the layer is "0", + /// the current layer, does not exist, or has entities. + fn delete_layer(&mut self, name: &str) -> ApiResult<()>; + + /// Look up a layer by raw name, normalizing it. Default implementation scans + /// `layers()`; backends with O(1) lookup should override. + fn lookup_layer(&self, name: &str) -> ApiResult { + let norm = crate::layer::normalize_layer_name(name); + if norm.is_empty() { + return Err(ApiError::validation("LookupLayer", "empty layer name")); + } + self.layers()? + .into_iter() + .find(|l| l.normalized_name() == norm) + .ok_or_else(|| { + ApiError::validation( + "LookupLayer", + format!("layer '{name}' does not exist"), + ) + }) + } + + /// Move an entity to a different layer. The target layer must exist. + fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()>; + + /// List all layers in the document. + fn layers(&self) -> ApiResult>; + + /// The layer name of an entity. + fn entity_layer(&self, id: ObjectId) -> ApiResult; + + /// Enumerate first-class entities, optionally filtered by kind and/or layer. + /// `include_bounds` controls whether `EntityView::bounds` is populated; skip it + /// for lightweight list views. + fn enumerate_entities( + &self, + kind: Option<&str>, + layer: Option<&str>, + include_bounds: bool, + ) -> ApiResult>; + + /// The WCS location of a Point entity. + fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]>; + + /// The start and end WCS points of a Line entity. + fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])>; + /// Can `id` be modified in place right now (exists, is the expected family, /// not on a locked layer)? Read-only pre-check used before mutations. /// Default: existence + not-locked (backends narrow the family check). @@ -178,6 +257,10 @@ pub trait DocApiBackend { } } + /// Whether an object (named-object / XRecord / dictionary) exists. Distinct + /// from `entity_exists`, which is for first-class entities only. + fn object_exists(&self, id: ObjectId) -> bool; + /// A generic, untyped view of any entity (id + kind + coarse bounds). fn get_entity(&mut self, id: ObjectId) -> ApiResult; diff --git a/crates/ocs_doc_api/src/convert.rs b/crates/ocs_doc_api/src/convert.rs index 4f91cb4c4..db1f0a22f 100644 --- a/crates/ocs_doc_api/src/convert.rs +++ b/crates/ocs_doc_api/src/convert.rs @@ -113,19 +113,19 @@ pub fn bulge_arc_segment( pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { crate::validation::curve(spec)?; let entity = match spec { - Curve2Spec::Line { start, end } => { + Curve2Spec::Line { start, end, .. } => { let mut e = Line::new(); e.start = v3(*start); e.end = v3(*end); EntityType::Line(e) } - Curve2Spec::Circle { centre, radius } => { + Curve2Spec::Circle { centre, radius, .. } => { let mut e = Circle::new(); e.center = v3(*centre); e.radius = *radius; EntityType::Circle(e) } - Curve2Spec::Polyline { points, closed } => { + Curve2Spec::Polyline { points, closed, .. } => { let mut e = LwPolyline::new(); e.vertices = points .iter() @@ -147,7 +147,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { e.is_closed = *closed; EntityType::LwPolyline(e) } - Curve2Spec::Point { position } => { + Curve2Spec::Point { position, .. } => { let mut e = Point::new(); e.location = v3(*position); EntityType::Point(e) @@ -157,6 +157,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { radius, start_angle, end_angle, + .. } => { let mut e = Arc::new(); e.center = v3(*centre); @@ -171,6 +172,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { ratio, start, end, + .. } => { // Validate the minor/major ratio so the (major-axis) bounds never // under-bound: ratio must be in (0, 1]. @@ -193,6 +195,7 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { control_points, knots, weights, + .. } => { let mut e = Spline::new(); e.degree = *degree; @@ -201,10 +204,10 @@ pub fn curve_spec_to_entity(spec: &Curve2Spec) -> ApiResult { e.weights = weights.clone(); EntityType::Spline(e) } - Curve2Spec::Ray { origin, direction } => { + Curve2Spec::Ray { origin, direction, .. } => { EntityType::Ray(Ray::new(v3(*origin), v3(*direction))) } - Curve2Spec::XLine { origin, direction } => { + Curve2Spec::XLine { origin, direction, .. } => { EntityType::XLine(XLine::new(v3(*origin), v3(*direction))) } }; diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 5f8cbce0f..0d11ad799 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -25,14 +25,35 @@ pub fn apply_op(b: &mut B, op: Operation) -> ApiResult(b: &mut B, op: Operation) -> ApiResult { let name = op.op_name(); crate::validation::operation(&op)?; - if let Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) = &op { - let unique: std::collections::HashSet<_> = ids.iter().collect(); - if ids.is_empty() || unique.len() != ids.len() { - return Err(ApiError::validation( - name, - "batch ids must be nonempty and unique", - )); + match &op { + Operation::TransformMany { ids, .. } | Operation::DeleteMany(ids) => { + let unique: std::collections::HashSet<_> = ids.iter().collect(); + if ids.is_empty() || unique.len() != ids.len() { + return Err(ApiError::validation( + name, + "batch ids must be nonempty and unique", + )); + } } + Operation::SetXDataMany(records) => { + if records.is_empty() { + return Err(ApiError::validation( + name, + "SetXDataMany records must be nonempty", + )); + } + if records.len() > BULK_ITEM_CAP { + return Err(over_cap(name, records.len())); + } + let unique: std::collections::HashSet<_> = records.iter().map(|(id, _, _)| id).collect(); + if unique.len() != records.len() { + return Err(ApiError::validation( + name, + "SetXDataMany records must have unique entity ids", + )); + } + } + _ => {} } if let Operation::Transform { placement, .. } | Operation::TransformMany { placement, .. } = &op { @@ -353,6 +374,70 @@ fn apply_op_inner(b: &mut B, op: Operation) -> ApiResult { + b.push_undo(name); + b.create_layer(info)?; + b.finalize_op(); + OpOutcome::Updated(ObjectId::NULL) + } + Operation::UpdateLayer { name, info } => { + b.push_undo(name); + b.update_layer(name, info)?; + b.finalize_op(); + OpOutcome::Updated(ObjectId::NULL) + } + Operation::DeleteLayer { name } => { + b.push_undo(name); + b.delete_layer(name)?; + b.finalize_op(); + OpOutcome::Updated(ObjectId::NULL) + } + Operation::SetEntityLayer { id, layer } => { + require_exists(b, *id, name)?; + b.push_undo(name); + b.set_entity_layer(*id, layer)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } + Operation::SetXData { + id, + application_name, + record, + } => { + require_exists(b, *id, name)?; + b.push_undo(name); + b.set_xdata(*id, application_name, record.as_ref())?; + b.finalize_op(); + OpOutcome::Updated(*id) + } + Operation::SetXDataMany(records) => { + // Pre-validate every entity id before any mutation. + for (id, app, _) in records { + require_exists(b, *id, name)?; + if app.is_empty() { + return Err(ApiError::validation(name, "empty application_name")); + } + } + b.push_undo(name); + for (id, application_name, record) in records { + b.set_xdata(*id, application_name, record.as_ref())?; + } + b.finalize_op(); + OpOutcome::Updated(ObjectId::NULL) + } + Operation::CreateXRecord(spec) => { + b.push_undo(name); + let id = b.add_xrecord(spec)?; + b.finalize_op(); + OpOutcome::NewId(id) + } + Operation::SetXRecord { id, spec } => { + require_object_exists(b, *id, name)?; + b.push_undo(name); + b.set_xrecord(*id, spec)?; + b.finalize_op(); + OpOutcome::Updated(*id) + } }; Ok(Receipt { outcome: Some(outcome), @@ -417,6 +502,38 @@ pub fn apply_queries(b: &mut B, queries: Vec) -> ApiRes let (target, height) = b.viewport_view(*id).map_err(|e| label(qname, e))?; QueryResult::ViewportView { target, height } } + Query::GetXData { + id, + application_name, + } => QueryResult::XData( + b.xdata(*id, application_name) + .map_err(|e| label(qname, e))? + .clone(), + ), + Query::GetXRecord { id } => { + QueryResult::XRecord(b.xrecord(*id).map_err(|e| label(qname, e))?.ok_or_else( + || ApiError::validation(qname, format!("ObjectId {id:?} is not an XRecord")), + )?) + } + Query::ListLayers => QueryResult::Layers(b.layers().map_err(|e| label(qname, e))?), + Query::GetEntityLayer { id } => { + QueryResult::EntityLayer(b.entity_layer(*id).map_err(|e| label(qname, e))?) + } + Query::EnumerateEntities { + kind, + layer, + include_bounds, + } => QueryResult::Entities( + b.enumerate_entities(kind.as_deref(), layer.as_deref(), *include_bounds) + .map_err(|e| label(qname, e))?, + ), + Query::GetPointPosition { id } => QueryResult::PointPosition( + b.point_position(*id).map_err(|e| label(qname, e))?, + ), + Query::GetLineGeometry { id } => { + let (start, end) = b.line_geometry(*id).map_err(|e| label(qname, e))?; + QueryResult::LineGeometry((start, end)) + } }; results.push(r); } @@ -459,6 +576,15 @@ fn require_exists(b: &B, id: ObjectId, op: &'static str) -> Ap } } +/// Validate that a named object exists before performing an operation. +fn require_object_exists(b: &B, id: ObjectId, op: &'static str) -> ApiResult<()> { + if b.object_exists(id) { + Ok(()) + } else { + Err(ApiError::validation(op, format!("unknown ObjectId {id:?}"))) + } +} + fn over_cap(op: &'static str, n: usize) -> ApiError { ApiError::validation(op, format!("bulk op over cap: {n} > {BULK_ITEM_CAP}")) } diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 97cc4d0d6..104b0a853 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -13,7 +13,7 @@ use crate::envelope::{DocApiEnvelope, Receipt}; use crate::error::{ApiError, ApiResult}; use crate::gen::{Operation, Query}; use crate::id::ObjectId; -use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive}; +use crate::ops::{BoolOp, Curve2Spec, PlacementSpec, SolidPrimitive, XDataRecord, XRecordSpec}; use crate::query::{Aabb, EntityView, QueryResult}; use crate::revision::GeometryRevision; use crate::transport::Transport; @@ -170,6 +170,74 @@ impl Document { } } + /// List all layers in the document. + pub fn layers(&self) -> ApiResult> { + match self.session.one_query(Query::ListLayers)? { + QueryResult::Layers(v) => Ok(v), + _ => Err(ApiError::Transport("unexpected layers result".into())), + } + } + + /// Create a new layer. One undo step; fails if the normalized name already + /// exists or is empty. + pub fn create_layer(&self, info: &crate::ops::LayerInfo) -> ApiResult<()> { + if !crate::layer::is_valid_layer_name(&info.name) { + return Err(ApiError::validation( + "CreateLayer", + "empty layer name", + )); + } + self.session + .apply_op(Operation::CreateLayer(info.clone()))?; + Ok(()) + } + + /// Update an existing layer's properties by name. One undo step; `info.name` + /// becomes the new display name (case-insensitive key unchanged). + pub fn update_layer(&self, name: &str, info: &crate::ops::LayerInfo) -> ApiResult<()> { + if !crate::layer::is_valid_layer_name(&info.name) { + return Err(ApiError::validation( + "UpdateLayer", + "empty layer name", + )); + } + self.session.apply_op(Operation::UpdateLayer { + name: name.to_string(), + info: info.clone(), + })?; + Ok(()) + } + + /// Delete a layer by name. One undo step; fails for layer "0", the current + /// layer, or any layer still referenced by entities. + pub fn delete_layer(&self, name: &str) -> ApiResult<()> { + self.session.apply_op(Operation::DeleteLayer { + name: name.to_string(), + })?; + Ok(()) + } + + /// Set XDATA on many entities in a single atomic operation. + /// + /// Each tuple is `(entity_id, application_name, record)`. Pass `None` for + /// `record` to delete the application's data. Fails up-front if any id is + /// invalid; the whole batch is one undo step. + pub fn set_xdata_many(&self, items: I) -> ApiResult<()> + where + I: IntoIterator)>, + S: Into, + { + let updates: Vec<_> = items + .into_iter() + .map(|(id, app, record)| (id, app.into(), record)) + .collect(); + if updates.is_empty() { + return Ok(()); + } + self.session.apply_op(Operation::SetXDataMany(updates))?; + Ok(()) + } + /// Batch of read-only queries in ONE round-trip (safe: no mutation/undo). /// The closure records queries on a [`QueryBatch`]; the results are returned /// in the same order as a [`QueryResults`] view the caller destructures. @@ -336,6 +404,25 @@ macro_rules! handle { })?; Ok(()) } + /// Return the entity's current layer name (normalized uppercase). + pub fn layer(&self) -> ApiResult { + match self + .session + .one_query(Query::GetEntityLayer { id: self.id })? + { + QueryResult::EntityLayer(name) => Ok(name), + _ => Err(ApiError::Transport("unexpected entity layer result".into())), + } + } + /// Move the entity to a different layer. One undo step; fails if the + /// target layer does not exist or is locked. + pub fn set_layer(&self, layer: &str) -> ApiResult<()> { + self.session.apply_op(Operation::SetEntityLayer { + id: self.id, + layer: layer.to_string(), + })?; + Ok(()) + } } }; } @@ -354,6 +441,25 @@ handle!(XLine); handle!(Text); handle!(MText); handle!(Dimension); +handle!(XRecord); + +impl XRecord { + /// Read the XRECORD payload. + pub fn payload(&self) -> ApiResult { + match self.session.one_query(Query::GetXRecord { id: self.id })? { + QueryResult::XRecord(spec) => Ok(spec), + _ => Err(ApiError::Transport("unexpected xrecord result".into())), + } + } + /// Replace the XRECORD payload in place (one undo step). + pub fn set_payload(&self, spec: &XRecordSpec) -> ApiResult<()> { + self.session.apply_op(Operation::SetXRecord { + id: self.id, + spec: spec.clone(), + })?; + Ok(()) + } +} impl Dimension { /// The measured value of this dimension (distance for linear/radius, degrees @@ -416,6 +522,26 @@ impl Entity { })?; Ok(()) } + /// The XDATA record for `application_name` on this entity (`None` if absent). + pub fn xdata(&self, application_name: &str) -> ApiResult> { + match self.session.one_query(Query::GetXData { + id: self.id, + application_name: application_name.to_string(), + })? { + QueryResult::XData(v) => Ok(v), + _ => Err(ApiError::Transport("unexpected xdata result".into())), + } + } + /// Attach or replace an XDATA record for `application_name` on this entity. + /// Pass `None` to remove the record (one undo step). + pub fn set_xdata(&self, application_name: &str, record: Option) -> ApiResult<()> { + self.session.apply_op(Operation::SetXData { + id: self.id, + application_name: application_name.to_string(), + record, + })?; + Ok(()) + } pub fn as_solid(&self) -> Option { // Typed downcast is validated by the view's kind. matches!(self.view().ok()?.kind.as_str(), "Solid3D") @@ -621,22 +747,80 @@ impl CurveCollection { Ok((self.session.clone(), id)) } pub fn create_line(&self, start: [f64; 3], end: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Line { start, end })?; + let (s, id) = self.create_curve(Curve2Spec::Line { + start, + end, + layer: None, + })?; + Ok(Line::new(s, id)) + } + pub fn create_line_on_layer( + &self, + start: [f64; 3], + end: [f64; 3], + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Line { + start, + end, + layer: Some(layer.to_string()), + })?; Ok(Line::new(s, id)) } pub fn create_circle(&self, centre: [f64; 3], radius: f64) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Circle { centre, radius })?; + let (s, id) = self.create_curve(Curve2Spec::Circle { + centre, + radius, + layer: None, + })?; + Ok(Circle::new(s, id)) + } + pub fn create_circle_on_layer( + &self, + centre: [f64; 3], + radius: f64, + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Circle { + centre, + radius, + layer: Some(layer.to_string()), + })?; Ok(Circle::new(s, id)) } pub fn create_polyline(&self, points: &[[f64; 3]], closed: bool) -> ApiResult { let (s, id) = self.create_curve(Curve2Spec::Polyline { points: points.to_vec(), closed, + layer: None, + })?; + Ok(Polyline::new(s, id)) + } + pub fn create_polyline_on_layer( + &self, + points: &[[f64; 3]], + closed: bool, + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Polyline { + points: points.to_vec(), + closed, + layer: Some(layer.to_string()), })?; Ok(Polyline::new(s, id)) } pub fn create_point(&self, position: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Point { position })?; + let (s, id) = self.create_curve(Curve2Spec::Point { + position, + layer: None, + })?; + Ok(Point::new(s, id)) + } + pub fn create_point_on_layer(&self, position: [f64; 3], layer: &str) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Point { + position, + layer: Some(layer.to_string()), + })?; Ok(Point::new(s, id)) } pub fn create_arc( @@ -651,6 +835,24 @@ impl CurveCollection { radius, start_angle, end_angle, + layer: None, + })?; + Ok(ArcCurve::new(s, id)) + } + pub fn create_arc_on_layer( + &self, + centre: [f64; 3], + radius: f64, + start_angle: f64, + end_angle: f64, + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Arc { + centre, + radius, + start_angle, + end_angle, + layer: Some(layer.to_string()), })?; Ok(ArcCurve::new(s, id)) } @@ -668,6 +870,26 @@ impl CurveCollection { ratio, start, end, + layer: None, + })?; + Ok(Ellipse::new(s, id)) + } + pub fn create_ellipse_on_layer( + &self, + centre: [f64; 3], + major_axis: [f64; 3], + ratio: f64, + start: f64, + end: f64, + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Ellipse { + centre, + major_axis, + ratio, + start, + end, + layer: Some(layer.to_string()), })?; Ok(Ellipse::new(s, id)) } @@ -683,15 +905,67 @@ impl CurveCollection { control_points: control_points.to_vec(), knots: knots.to_vec(), weights: weights.to_vec(), + layer: None, + })?; + Ok(Spline::new(s, id)) + } + pub fn create_spline_on_layer( + &self, + degree: i32, + control_points: &[[f64; 3]], + knots: &[f64], + weights: &[f64], + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Spline { + degree, + control_points: control_points.to_vec(), + knots: knots.to_vec(), + weights: weights.to_vec(), + layer: Some(layer.to_string()), })?; Ok(Spline::new(s, id)) } pub fn create_ray(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::Ray { origin, direction })?; + let (s, id) = self.create_curve(Curve2Spec::Ray { + origin, + direction, + layer: None, + })?; + Ok(Ray::new(s, id)) + } + pub fn create_ray_on_layer( + &self, + origin: [f64; 3], + direction: [f64; 3], + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::Ray { + origin, + direction, + layer: Some(layer.to_string()), + })?; Ok(Ray::new(s, id)) } pub fn create_xline(&self, origin: [f64; 3], direction: [f64; 3]) -> ApiResult { - let (s, id) = self.create_curve(Curve2Spec::XLine { origin, direction })?; + let (s, id) = self.create_curve(Curve2Spec::XLine { + origin, + direction, + layer: None, + })?; + Ok(XLine::new(s, id)) + } + pub fn create_xline_on_layer( + &self, + origin: [f64; 3], + direction: [f64; 3], + layer: &str, + ) -> ApiResult { + let (s, id) = self.create_curve(Curve2Spec::XLine { + origin, + direction, + layer: Some(layer.to_string()), + })?; Ok(XLine::new(s, id)) } /// A RASTER_IMAGE placed at `insertion_point` (host auto-registers the image definition). @@ -874,10 +1148,42 @@ impl CurveCollection { Ok(MText::new(self.session.clone(), id)) } /// Bulk-create many points in ONE op: all-or-nothing, one undo step. + /// Places points on the current layer (`layer: None`). pub fn create_points(&self, positions: &[[f64; 3]]) -> ApiResult> { let specs: Vec = positions .iter() - .map(|&p| crate::ops::EntitySpec::Curve(Curve2Spec::Point { position: p })) + .map(|&p| { + crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { + position: p, + layer: None, + }) + }) + .collect(); + let receipt = self.session.apply_op(Operation::CreateMany(specs))?; + let outcome = receipt + .outcome + .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; + Ok(outcome + .new_ids() + .iter() + .map(|&id| Point::new(self.session.clone(), id)) + .collect()) + } + + /// Bulk-create many points on a specific layer in ONE op. + pub fn create_points_on_layer( + &self, + positions: &[[f64; 3]], + layer: &str, + ) -> ApiResult> { + let specs: Vec = positions + .iter() + .map(|&p| { + crate::ops::EntitySpec::Curve(crate::ops::Curve2Spec::Point { + position: p, + layer: Some(layer.to_string()), + }) + }) .collect(); let receipt = self.session.apply_op(Operation::CreateMany(specs))?; let outcome = receipt @@ -1001,6 +1307,41 @@ impl EntityCollection { self.session.apply_op(Operation::Delete { id })?; Ok(()) } + + /// Create a standalone `XRECORD` object (returns an `Entity` handle). + pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult { + let receipt = self + .session + .apply_op(Operation::CreateXRecord(spec.clone()))?; + let id = receipt + .outcome + .and_then(|o| o.new_id()) + .ok_or_else(|| ApiError::Transport("create_xrecord returned no id".into()))?; + Ok(Entity::new(self.session.clone(), id)) + } + + /// Lookup an `XRECORD` object by id and return a typed accessor handle. + pub fn get_xrecord(&self, id: ObjectId) -> ApiResult { + // Validate existence + that the payload is readable as an XRecord. + match self.session.one_query(Query::GetXRecord { id })? { + QueryResult::XRecord(_) => Ok(XRecord::new(self.session.clone(), id)), + _ => Err(ApiError::Transport("unexpected xrecord result".into())), + } + } + + /// Bulk-create many entities in ONE op (all-or-nothing, one undo step). + pub fn create_many(&self, specs: &[crate::ops::EntitySpec]) -> ApiResult> { + let receipt = self.session.apply_op(Operation::CreateMany(specs.to_vec()))?; + let outcome = receipt + .outcome + .ok_or_else(|| ApiError::Transport("create_many returned no outcome".into()))?; + Ok(outcome + .new_ids() + .iter() + .map(|&id| Entity::new(self.session.clone(), id)) + .collect()) + } + /// Bulk transform (one op, all-or-nothing). pub fn transform_many(&self, ids: &[ObjectId], placement: PlacementSpec) -> ApiResult<()> { self.session.apply_op(Operation::TransformMany { diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 20cb2a0d6..3430545b6 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -192,6 +192,32 @@ Table: create_table(insertion_point, data[row][col]) builds rows x columns of te - **create_table**(insertion_point: [f64; 3], data: &[Vec]) -> `Entity` — `CreateTable` +## `XRecord` (acadrust `XRecord`, collection `entities`) + +Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped verbatim as an XRecordSpec payload. + +### Constructors (`doc.entities()`) + +- **create_xrecord**(spec: XRecordSpec) -> `Entity` — `CreateXRecord` + +### Methods + +- **payload**() -> `XRecordSpec` — query `GetXRecord` +- **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord` +- **xdata**(application_name: &str) -> `Option` — query `GetXData` +- **set_xdata**(application_name: &str, record: Option) -> `()` — op `SetXData` + +## `Layer` (acadrust `-`, collection `layers`) + +Named drawing layer (color, linetype, lineweight, visibility). Not an entity handle; accessed through Document::layers(). + +### Methods + +- **list**() -> `Vec` — query `ListLayers` +- **create**(info: LayerInfo) -> `()` — op `CreateLayer` +- **update**(name: String, info: LayerInfo) -> `()` — op `UpdateLayer` +- **delete**(name: String) -> `()` — op `DeleteLayer` + ## Generic methods (every handle) Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, @@ -204,10 +230,32 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, - **`transform(placement)`** — in-place rigid similarity (same `ObjectId`). **v1: solids only.** - **`delete()`** — remove the entity (one undo step). +- **`layer()` -> `String`** — the name of the layer the entity is on. +- **`set_layer(layer)`** — move the entity to an existing layer (one undo step; + blocked if the entity or target layer is locked). `Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed downcast when `kind == "Solid3D"`). +## Layer table + +Layers are a named document table, not an entity family. `Document::layers()` +lists all layers as `Vec`. Table-level ops are `CreateLayer(info)`, +`UpdateLayer { name, info }` and `DeleteLayer { name }`. `Entity::layer()` +returns the layer name and `Entity::set_layer(layer)` moves the entity to an +existing layer. The host rejects duplicate names, deletion of layer "0" or the +current layer, and removing a layer that still has entities assigned. + +## Transports + +- **`InProcess`** (feature `host`) — drives a `DocApiBackend` directly in the host. +- **`OcsPluginApiIpc`** (feature `ipc`) — serializes envelopes over the + `ocs_plugin_api` `PluginRequestSender`/`PluginRequest` channel. The host routes + `DocApiRequest { tab_id, bytes }` to the same executor. +- **`doc_api_for_host(host)`** (feature `doc_api_host`) — convenience helper that + builds a `DocApi` from any `ocs_plugin_api::host::HostApi` exposing a + `PluginRequestSender` (out-of-process / worker-thread plugins). + ## Collections & cross-cutting - **`DocApi`** — root: `document(tab)`, `active_tab()`, `alive()`. @@ -284,6 +332,14 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read- - `CreateAttributeDefinition(AttributeDefinitionSpec)` - `CreateTable(TableSpec)` - `CreateDimensionAngular2Ln(DimensionAngularSpec)` +- `SetXData { id: ObjectId, application_name: String, record: Option }` +- `CreateXRecord(XRecordSpec)` +- `SetXRecord { id: ObjectId, spec: XRecordSpec }` +- `CreateLayer(LayerInfo)` +- `UpdateLayer { name: String, info: LayerInfo }` +- `DeleteLayer { name: String }` +- `SetEntityLayer { id: ObjectId, layer: String }` +- `SetXDataMany(Vec<(ObjectId, String, Option)` ### `Query` @@ -299,6 +355,10 @@ Every `Operation` is ONE atomic write op (one undo step); every `Query` is read- - `GetAttributes { id: ObjectId }` - `GetBlockEntities { block_name: String }` - `GetViewportView { id: ObjectId }` +- `GetXData { id: ObjectId, application_name: String }` +- `GetXRecord { id: ObjectId }` +- `ListLayers` +- `GetEntityLayer { id: ObjectId }` ## Errors (`ApiError`) diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 90f422758..3a7d454c6 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -1124,6 +1124,164 @@ "handle": "Entity", "methods": [], "name": "Table" + }, + { + "acadrust_variant": "XRecord", + "collection": "entities", + "constructors": [ + { + "args": [ + { + "name": "spec", + "ty": "XRecordSpec" + } + ], + "bulk": false, + "fixed": null, + "name": "create_xrecord", + "op": "CreateXRecord", + "returns": "Entity" + } + ], + "handle": "XRecord", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "payload", + "op": null, + "query": "GetXRecord", + "read_only": true, + "returns": "XRecordSpec" + }, + { + "args": [ + { + "name": "spec", + "ty": "XRecordSpec" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_payload", + "op": "SetXRecord", + "query": null, + "read_only": false, + "returns": "()" + }, + { + "args": [ + { + "name": "application_name", + "ty": "&str" + } + ], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "xdata", + "op": null, + "query": "GetXData", + "read_only": true, + "returns": "Option" + }, + { + "args": [ + { + "name": "application_name", + "ty": "&str" + }, + { + "name": "record", + "ty": "Option" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "set_xdata", + "op": "SetXData", + "query": null, + "read_only": false, + "returns": "()" + } + ], + "name": "XRecord" + }, + { + "acadrust_variant": null, + "collection": "layers", + "constructors": [], + "handle": "Layer", + "methods": [ + { + "args": [], + "bulk": false, + "fixed": null, + "kind": "query", + "name": "list", + "op": null, + "query": "ListLayers", + "read_only": true, + "returns": "Vec" + }, + { + "args": [ + { + "name": "info", + "ty": "LayerInfo" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "create", + "op": "CreateLayer", + "query": null, + "read_only": false, + "returns": "()" + }, + { + "args": [ + { + "name": "name", + "ty": "String" + }, + { + "name": "info", + "ty": "LayerInfo" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "update", + "op": "UpdateLayer", + "query": null, + "read_only": false, + "returns": "()" + }, + { + "args": [ + { + "name": "name", + "ty": "String" + } + ], + "bulk": false, + "fixed": null, + "kind": "op", + "name": "delete", + "op": "DeleteLayer", + "query": null, + "read_only": false, + "returns": "()" + } + ], + "name": "Layer" } ], "layouts": { @@ -1164,7 +1322,15 @@ "CreateDimensionAngular(DimensionAngularSpec)", "CreateAttributeDefinition(AttributeDefinitionSpec)", "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)" + "CreateDimensionAngular2Ln(DimensionAngularSpec)", + "SetXData { id: ObjectId, application_name: String, record: Option }", + "CreateXRecord(XRecordSpec)", + "SetXRecord { id: ObjectId, spec: XRecordSpec }", + "CreateLayer(LayerInfo)", + "UpdateLayer { name: String, info: LayerInfo }", + "DeleteLayer { name: String }", + "SetEntityLayer { id: ObjectId, layer: String }", + "SetXDataMany(Vec<(ObjectId, String, Option)" ] }, "Query": { @@ -1180,7 +1346,11 @@ "GetDimensionMeasurement { id: ObjectId }", "GetAttributes { id: ObjectId }", "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }" + "GetViewportView { id: ObjectId }", + "GetXData { id: ObjectId, application_name: String }", + "GetXRecord { id: ObjectId }", + "ListLayers", + "GetEntityLayer { id: ObjectId }" ] } }, @@ -1205,7 +1375,9 @@ "XLine", "Text", "MText", - "Dimension" + "Dimension", + "XRecord", + "Layer" ], "root": "DocApi" }, diff --git a/crates/ocs_doc_api/src/gen/layouts.json b/crates/ocs_doc_api/src/gen/layouts.json index c743ba25d..b43755336 100644 --- a/crates/ocs_doc_api/src/gen/layouts.json +++ b/crates/ocs_doc_api/src/gen/layouts.json @@ -29,7 +29,15 @@ "CreateDimensionAngular(DimensionAngularSpec)", "CreateAttributeDefinition(AttributeDefinitionSpec)", "CreateTable(TableSpec)", - "CreateDimensionAngular2Ln(DimensionAngularSpec)" + "CreateDimensionAngular2Ln(DimensionAngularSpec)", + "SetXData { id: ObjectId, application_name: String, record: Option }", + "CreateXRecord(XRecordSpec)", + "SetXRecord { id: ObjectId, spec: XRecordSpec }", + "CreateLayer(LayerInfo)", + "UpdateLayer { name: String, info: LayerInfo }", + "DeleteLayer { name: String }", + "SetEntityLayer { id: ObjectId, layer: String }", + "SetXDataMany(Vec<(ObjectId, String, Option)" ] }, "Query": { @@ -45,7 +53,11 @@ "GetDimensionMeasurement { id: ObjectId }", "GetAttributes { id: ObjectId }", "GetBlockEntities { block_name: String }", - "GetViewportView { id: ObjectId }" + "GetViewportView { id: ObjectId }", + "GetXData { id: ObjectId, application_name: String }", + "GetXRecord { id: ObjectId }", + "ListLayers", + "GetEntityLayer { id: ObjectId }" ] } } diff --git a/crates/ocs_doc_api/src/gen/ops_gen.rs b/crates/ocs_doc_api/src/gen/ops_gen.rs index 22ca691a3..7d8e91d22 100644 --- a/crates/ocs_doc_api/src/gen/ops_gen.rs +++ b/crates/ocs_doc_api/src/gen/ops_gen.rs @@ -10,7 +10,7 @@ use crate::id::ObjectId; use crate::ops::{ AttributeDefinitionSpec, BoolOp, Curve2Spec, DimensionAngularSpec, DimensionRadialSpec, DimensionSpec, EntitySpec, HatchSpec, InsertSpec, MTextSpec, PlacementSpec, RasterImageSpec, - SolidPrimitive, TableSpec, TextSpec, ViewportSpec, + SolidPrimitive, TableSpec, TextSpec, ViewportSpec, XDataRecord, XRecordSpec, }; /// A typed write operation. Each variant is ONE atomic host call = one @@ -99,4 +99,29 @@ pub enum Operation { CreateTable(TableSpec), // dimension sub-types (2-line angular) CreateDimensionAngular2Ln(DimensionAngularSpec), + // xdata / xrecord roundtrip + SetXData { + id: ObjectId, + application_name: String, + record: Option, + }, + SetXDataMany(Vec<(ObjectId, String, Option)>), + CreateXRecord(XRecordSpec), + SetXRecord { + id: ObjectId, + spec: XRecordSpec, + }, + // layer CRUD + CreateLayer(crate::ops::LayerInfo), + UpdateLayer { + name: String, + info: crate::ops::LayerInfo, + }, + DeleteLayer { + name: String, + }, + SetEntityLayer { + id: ObjectId, + layer: String, + }, } diff --git a/crates/ocs_doc_api/src/gen/query_gen.rs b/crates/ocs_doc_api/src/gen/query_gen.rs index efefde74a..f58c6a0c8 100644 --- a/crates/ocs_doc_api/src/gen/query_gen.rs +++ b/crates/ocs_doc_api/src/gen/query_gen.rs @@ -55,4 +55,35 @@ pub enum Query { GetViewportView { id: ObjectId, }, + /// The XDATA record for `application_name` attached to an entity (`None` if absent). + GetXData { + id: ObjectId, + application_name: String, + }, + /// The XRECORD object payload by id. + GetXRecord { + id: ObjectId, + }, + /// List all layers in the document. + ListLayers, + /// The layer name of an entity. + GetEntityLayer { + id: ObjectId, + }, + /// Enumerate first-class entities, optionally filtered by kind and/or layer. + /// `include_bounds` populates `EntityView::bounds`; leave it false for a + /// lightweight list view. + EnumerateEntities { + kind: Option, + layer: Option, + include_bounds: bool, + }, + /// The WCS location of a Point entity. + GetPointPosition { + id: ObjectId, + }, + /// The start and end WCS points of a Line entity. + GetLineGeometry { + id: ObjectId, + }, } diff --git a/crates/ocs_doc_api/src/layer.rs b/crates/ocs_doc_api/src/layer.rs new file mode 100644 index 000000000..48987f24f --- /dev/null +++ b/crates/ocs_doc_api/src/layer.rs @@ -0,0 +1,42 @@ +//! Shared layer-name normalization rules. +//! +//! Layer names in OpenCADStudio/acadrust are case-insensitive, leading/trailing +//! whitespace is ignored, and empty names are invalid. The host (`normalize_name` +//! in `acadrust::tables`) implements the canonical rule set; this module re-exports +//! a compatible, dependency-free function for the wire side and for backends that +//! do not link against `acadrust` directly. + +/// Normalize a raw layer name into its case-insensitive lookup key. +/// +/// This mirrors `acadrust::tables::normalize_name` used by the host: +/// - trims leading and trailing ASCII whitespace +/// - converts the result to uppercase +/// - returns `""` if the name is empty after trimming +pub fn normalize_layer_name(name: &str) -> String { + name.trim().to_ascii_uppercase() +} + +/// A normalized layer name is non-empty and matches the host lookup key. +pub fn is_valid_layer_name(name: &str) -> bool { + !normalize_layer_name(name).is_empty() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalization_trims_and_uppercases() { + assert_eq!(normalize_layer_name(" Walls "), "WALLS"); + assert_eq!(normalize_layer_name("walls"), "WALLS"); + assert_eq!(normalize_layer_name("WALLS"), "WALLS"); + } + + #[test] + fn empty_and_whitespace_names_are_invalid() { + assert_eq!(normalize_layer_name(""), ""); + assert_eq!(normalize_layer_name(" "), ""); + assert!(!is_valid_layer_name("")); + assert!(!is_valid_layer_name(" ")); + } +} diff --git a/crates/ocs_doc_api/src/lib.rs b/crates/ocs_doc_api/src/lib.rs index 4949b82d6..649f4bc84 100644 --- a/crates/ocs_doc_api/src/lib.rs +++ b/crates/ocs_doc_api/src/lib.rs @@ -17,6 +17,7 @@ mod validation; pub mod envelope; pub mod facade; +pub mod layer; pub mod ops; pub mod query; pub mod transport; @@ -41,9 +42,14 @@ pub use envelope::{DocApiEnvelope, EnvelopeBody, OpOutcome, Receipt}; pub use facade::{ ArcCurve, Circle, CurveCollection, Dimension, DocApi, Document, Ellipse, Entity, EntityCollection, HasId, Line, MText, OpGroup, Point, Polyline, QueryBatch, QueryResults, Ray, - Solid, SolidCollection, Spline, Text, XLine, + Solid, SolidCollection, Spline, Text, XLine, XRecord, +}; +pub use layer::{is_valid_layer_name, normalize_layer_name}; +pub use ops::{ + BoolOp, Color, Curve2Spec, EntitySpec, LayerFlags, LayerInfo, LineWeight, Operation, + PlacementSpec, SolidPrimitive, XDataRecord, XDataValue, XRecordCloningFlags, XRecordEntry, + XRecordSpec, XRecordValue, }; -pub use ops::{BoolOp, Curve2Spec, EntitySpec, Operation, PlacementSpec, SolidPrimitive}; pub use query::{Aabb, EntityView, Query, QueryResult}; #[cfg(feature = "host")] @@ -51,6 +57,9 @@ pub use convert::{ bulge_arc_segment, curve_spec_to_entity, entity_bounds, entity_kind_name, entity_to_profile_curves, transform_entity_geometry, }; +#[cfg(feature = "doc_api_host")] +pub use transport::ipc::doc_api_for_host; + pub use transport::Transport; /// The crate's own wire-envelope protocol version (`DocApiEnvelope::version`). diff --git a/crates/ocs_doc_api/src/ops.rs b/crates/ocs_doc_api/src/ops.rs index c41d7cb1c..7941b4112 100644 --- a/crates/ocs_doc_api/src/ops.rs +++ b/crates/ocs_doc_api/src/ops.rs @@ -8,6 +8,80 @@ use serde::{Deserialize, Serialize}; pub use crate::gen::Operation; +/// An XDATA record payload: one registered application name plus its typed +/// value list. This is a plain-data, serde-compatible mirror of +/// `acadrust::xdata::ExtendedDataRecord` so the wire DTO stays independent of +/// the host dependency. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct XDataRecord { + pub application_name: String, + pub values: Vec, +} + +/// A single XDATA value. Mirrors `acadrust::xdata::XDataValue`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum XDataValue { + String(String), + ControlString(String), + LayerName(String), + BinaryData(Vec), + Handle(u64), + Point3D([f64; 3]), + Position3D([f64; 3]), + Displacement3D([f64; 3]), + Direction3D([f64; 3]), + Real(f64), + Distance(f64), + ScaleFactor(f64), + Integer16(i16), + Integer32(i32), +} + +/// Construction spec for an `XRECORD` object. Mirrors the acadrust +/// `XRecord` payload (name + cloning flags + group-code entries) in a +/// serde-compatible, host-independent form. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct XRecordSpec { + pub name: String, + pub cloning_flags: XRecordCloningFlags, + pub entries: Vec, +} + +/// XRecord cloning behavior flags. Mirror of +/// `acadrust::objects::DictionaryCloningFlags`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum XRecordCloningFlags { + #[default] + NotApplicable, + KeepExisting, + UseClone, + XrefName, + Name, + UnmangleName, +} + +/// One XRECORD group-code entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct XRecordEntry { + pub code: i32, + pub value: XRecordValue, +} + +/// A single XRECORD value. Mirrors `acadrust::objects::XRecordValue`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum XRecordValue { + String(String), + Double(f64), + Int16(i16), + Int32(i32), + Int64(i64), + Byte(u8), + Bool(bool), + Handle(u64), + Point3D([f64; 3]), + Chunk(Vec), +} + /// Boolean operation kind for [`Operation::SolidBoolean`]. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BoolOp { @@ -16,6 +90,72 @@ pub enum BoolOp { Difference, } +/// A plain-data color value. Mirrors `acadrust::types::Color` for wire +/// independence from the host dependency. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Color { + ByLayer, + None, + ByBlock, + Index(u8), + Rgb { r: u8, g: u8, b: u8 }, +} + +impl Color { + pub const WHITE: Color = Color::Index(7); +} + +/// A plain-data line weight. Mirrors `acadrust::types::LineWeight`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub enum LineWeight { + #[default] + ByLayer, + ByBlock, + Default, + /// Specific line weight in 1/100 mm. + Value(i16), +} + +/// Layer visibility/lock flags. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct LayerFlags { + pub frozen: bool, + pub locked: bool, + pub frozen_in_new_viewport: bool, + pub off: bool, +} + +/// A plain-data layer table entry. Mirrors `acadrust::tables::Layer`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct LayerInfo { + pub name: String, + pub flags: LayerFlags, + pub color: Color, + pub line_type: String, + pub line_weight: LineWeight, + pub plot_style: String, + pub is_plottable: bool, +} + +impl LayerInfo { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + flags: LayerFlags::default(), + color: Color::WHITE, + line_type: "Continuous".to_string(), + line_weight: LineWeight::Default, + plot_style: String::new(), + is_plottable: true, + } + } + + /// The normalized (trimmed + uppercased) lookup key for this layer name. + pub fn normalized_name(&self) -> String { + crate::layer::normalize_layer_name(&self.name) + } +} + /// Construction spec for a B-rep solid primitive (plain-data mirror of the /// non-serde `cadkernel::brep::make::*` arguments). #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] @@ -55,17 +195,25 @@ pub enum Curve2Spec { Line { start: [f64; 3], end: [f64; 3], + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, Circle { centre: [f64; 3], radius: f64, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, Polyline { points: Vec<[f64; 3]>, closed: bool, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, Point { position: [f64; 3], + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// Circular arc, counter-clockwise from `start_angle` to `end_angle` (radians). Arc { @@ -73,6 +221,8 @@ pub enum Curve2Spec { radius: f64, start_angle: f64, end_angle: f64, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// Ellipse: `major_axis` is the major-axis endpoint relative to `centre`; /// `ratio` = minor/major. `start`/`end` parameters 0..2π (full ellipse = 0..2π). @@ -82,6 +232,8 @@ pub enum Curve2Spec { ratio: f64, start: f64, end: f64, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// NURBS spline (control points + knots + weights; `degree` typically 3). Spline { @@ -89,19 +241,42 @@ pub enum Curve2Spec { control_points: Vec<[f64; 3]>, knots: Vec, weights: Vec, + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// A ray from `origin` along `direction` (bounded at origin only). Ray { origin: [f64; 3], direction: [f64; 3], + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, /// An infinite construction line through `origin` along `direction`. XLine { origin: [f64; 3], direction: [f64; 3], + /// Target layer name. `None` places the entity on the current layer. + layer: Option, }, } +impl Curve2Spec { + /// The target layer name, if any. + pub fn layer(&self) -> Option<&str> { + match self { + Curve2Spec::Line { layer, .. } + | Curve2Spec::Circle { layer, .. } + | Curve2Spec::Polyline { layer, .. } + | Curve2Spec::Point { layer, .. } + | Curve2Spec::Arc { layer, .. } + | Curve2Spec::Ellipse { layer, .. } + | Curve2Spec::Spline { layer, .. } + | Curve2Spec::Ray { layer, .. } + | Curve2Spec::XLine { layer, .. } => layer.as_deref(), + } + } +} + /// A generic entity-construction payload used by the bulk op /// [`crate::ops::Operation::CreateMany`]. Mirrors the per-family construction /// specs so one bulk op can carry many homogeneous (or mixed) creations. @@ -111,6 +286,16 @@ pub enum EntitySpec { Solid(SolidPrimitive), } +impl EntitySpec { + /// The target layer name for this spec, if explicitly set. + pub fn layer(&self) -> Option<&str> { + match self { + EntitySpec::Curve(c) => c.layer(), + EntitySpec::Solid(_) => None, + } + } +} + /// Construction spec for a block reference (`INSERT`): place the block /// `block_name` (must exist as a BlockRecord) at `insert_point` with uniform /// `scale` and `rotation` (radians, about Z). @@ -291,6 +476,14 @@ impl crate::gen::Operation { CreateAttributeDefinition(_) => "CreateAttributeDefinition", CreateTable(_) => "CreateTable", CreateDimensionAngular2Ln(_) => "CreateDimensionAngular2Ln", + SetXData { .. } => "SetXData", + SetXDataMany(_) => "SetXDataMany", + CreateXRecord(_) => "CreateXRecord", + SetXRecord { .. } => "SetXRecord", + CreateLayer(_) => "CreateLayer", + UpdateLayer { .. } => "UpdateLayer", + DeleteLayer { .. } => "DeleteLayer", + SetEntityLayer { .. } => "SetEntityLayer", } } } diff --git a/crates/ocs_doc_api/src/query.rs b/crates/ocs_doc_api/src/query.rs index 4ce1b1ba5..e53d2b650 100644 --- a/crates/ocs_doc_api/src/query.rs +++ b/crates/ocs_doc_api/src/query.rs @@ -41,6 +41,8 @@ pub struct EntityView { /// The acadrust `EntityType` variant name (e.g. "Solid3D", "Line"). pub kind: String, pub bounds: Option, + /// The normalized (uppercase) layer name the entity is on. + pub layer: String, } /// The result of a single [`Query`] (one per query in a `Queries` batch). @@ -68,6 +70,20 @@ pub enum QueryResult { target: [f64; 3], height: f64, }, + /// An XDATA record for a single application (`None` if no such record). + XData(Option), + /// An XRECORD object payload. + XRecord(crate::ops::XRecordSpec), + /// All layers in the document. + Layers(Vec), + /// The layer name of an entity. + EntityLayer(String), + /// A filtered list of entity views. + Entities(Vec), + /// The WCS location of a Point entity. + PointPosition([f64; 3]), + /// The start and end WCS points of a Line entity. + LineGeometry(([f64; 3], [f64; 3])), } /// Convenience: the query name for diagnostics. @@ -87,6 +103,13 @@ impl crate::gen::Query { GetAttributes { .. } => "GetAttributes", GetBlockEntities { .. } => "GetBlockEntities", GetViewportView { .. } => "GetViewportView", + GetXData { .. } => "GetXData", + GetXRecord { .. } => "GetXRecord", + ListLayers => "ListLayers", + GetEntityLayer { .. } => "GetEntityLayer", + EnumerateEntities { .. } => "EnumerateEntities", + GetPointPosition { .. } => "GetPointPosition", + GetLineGeometry { .. } => "GetLineGeometry", } } } diff --git a/crates/ocs_doc_api/src/transport/ipc.rs b/crates/ocs_doc_api/src/transport/ipc.rs index 74bd4f06f..f9b4b1d3e 100644 --- a/crates/ocs_doc_api/src/transport/ipc.rs +++ b/crates/ocs_doc_api/src/transport/ipc.rs @@ -5,11 +5,12 @@ use std::sync::Arc; -use ocs_plugin_api::host::{PluginRequestError, PluginRequestSender}; +use ocs_plugin_api::host::{HostApi, PluginRequestError, PluginRequestSender}; use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; use crate::envelope::{DocApiEnvelope, Receipt}; use crate::error::{ApiError, ApiResult}; +use crate::facade::DocApi; use crate::transport::Transport; /// Out-of-process IPC transport over the `ocs_plugin_api` channel. `Send + Sync`; @@ -29,6 +30,21 @@ impl OcsPluginApiIpc { } } +/// Build a typed [`DocApi`] from any host that can provide a +/// [`PluginRequestSender`] (out-of-process / V4 worker plugins). +/// +/// Returns `None` for hosts that do not expose a request sender (e.g. in-process +/// hosts). In-process code should use [`crate::DocApi::in_process`] with a +/// concrete [`crate::backend::DocApiBackend`]. +pub fn doc_api_for_host(host: &dyn HostApi) -> Option { + let sender = host.plugin_request_sender()?; + let tab_id = host.tab_id(); + Some(DocApi::connect( + Arc::new(OcsPluginApiIpc::new(sender.into(), tab_id)), + tab_id, + )) +} + impl Transport for OcsPluginApiIpc { fn apply(&self, req: DocApiEnvelope) -> ApiResult { let bytes = bincode::serialize(&req) @@ -56,3 +72,128 @@ impl Transport for OcsPluginApiIpc { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::facade::HasId; + use crate::ObjectId; + use ocs_plugin_api::host::{ + CadDocument, EntityType, ExtendedDataRecord, Handle, HostApi, + }; + use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; + use std::any::Any; + use std::sync::{Arc, Mutex}; + + #[derive(Clone)] + struct MockSender { + captured: Arc>>, + } + + impl PluginRequestSender for MockSender { + fn request(&self, req: PluginRequest) -> Result { + let tab_id = match &req { + PluginRequest::DocApiRequest { tab_id, .. } => *tab_id, + _ => 0, + }; + self.captured.lock().unwrap().push((tab_id, req)); + Ok(PluginResponse::DocApiResponse { + bytes: bincode::serialize(&crate::ApiResult::Ok(Receipt { + outcome: Some(crate::envelope::OpOutcome::NewId(ObjectId::from_u64(7))), + query_results: vec![], + new_revision: crate::revision::GeometryRevision(1), + })) + .unwrap(), + }) + } + } + + struct MockHost { + sender: Option, + } + + impl HostApi for MockHost { + fn tab_index(&self) -> usize { + 0 + } + fn document(&self) -> &CadDocument { + unimplemented!() + } + fn document_mut(&mut self) -> &mut CadDocument { + unimplemented!() + } + fn add_entity(&mut self, _entity: EntityType) -> Handle { + unimplemented!() + } + fn bump_geometry(&mut self) {} + fn push_undo(&mut self, _label: &str) {} + fn set_dirty(&mut self) {} + fn push_info(&mut self, _msg: &str) {} + fn push_output(&mut self, _msg: &str) {} + fn push_error(&mut self, _msg: &str) {} + fn start_interactive(&mut self, _command: Box) {} + fn read_record(&self, _handle: Handle, _app_name: &str) -> Option<&ExtendedDataRecord> { + None + } + fn write_record(&mut self, _handle: Handle, _record: ExtendedDataRecord) -> bool { + false + } + fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool { + false + } + fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> { + None + } + fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> { + None + } + fn ensure_plugin_state_any( + &mut self, + _plugin_id: &'static str, + _init: &mut dyn FnMut() -> Box, + ) -> &mut (dyn Any + Send + Sync) { + unimplemented!() + } + fn document_reader(&self) -> Box { + unimplemented!() + } + fn plugin_request_sender(&self) -> Option> { + self.sender.as_ref().map(|s| Box::new(s.clone()) as Box) + } + } + + #[test] + fn doc_api_for_host_builds_doc_api_and_routes_doc_api_request() { + let captured = Arc::new(Mutex::new(Vec::new())); + let host = MockHost { + sender: Some(MockSender { + captured: Arc::clone(&captured), + }), + }; + let doc_api = doc_api_for_host(&host).expect("host exposes a sender"); + let doc = doc_api.document(host.tab_id()); + let id = doc + .solids() + .create_cuboid([0.0; 3], [1.0; 3]) + .expect("mock returns a NewId receipt"); + assert_eq!(id.id(), ObjectId::from_u64(7)); + + let calls = captured.lock().unwrap(); + assert_eq!(calls.len(), 1); + let (tab_id, ref req) = &calls[0]; + assert_eq!(*tab_id, host.tab_id()); + match req { + PluginRequest::DocApiRequest { tab_id, bytes } => { + assert_eq!(*tab_id, host.tab_id()); + assert!(!bytes.is_empty()); + } + other => panic!("expected DocApiRequest, got {other:?}"), + } + } + + #[test] + fn doc_api_for_host_returns_none_without_sender() { + let host = MockHost { sender: None }; + assert!(doc_api_for_host(&host).is_none()); + } +} diff --git a/crates/ocs_doc_api/src/validation.rs b/crates/ocs_doc_api/src/validation.rs index 8a96a3710..a6e93067a 100644 --- a/crates/ocs_doc_api/src/validation.rs +++ b/crates/ocs_doc_api/src/validation.rs @@ -1,5 +1,5 @@ //! Reject invalid numeric inputs before the backend starts an undo operation. -use crate::{ops::*, ApiError, ApiResult}; +use crate::{id::ObjectId, ops::*, ApiError, ApiResult}; fn finite(values: &[f64]) -> bool { values.iter().all(|v| v.is_finite()) @@ -24,16 +24,17 @@ fn check(valid: bool, op: &'static str) -> ApiResult<()> { pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { use Curve2Spec::*; let valid = match spec { - Line { start, end } => finite(start) && finite(end) && start != end, - Circle { centre, radius } => finite(centre) && positive(*radius), - Point { position } => finite(position), + Line { start, end, .. } => finite(start) && finite(end) && start != end, + Circle { centre, radius, .. } => finite(centre) && positive(*radius), + Point { position, .. } => finite(position), Arc { centre, radius, start_angle, end_angle, + .. } => finite(centre) && positive(*radius) && finite(&[*start_angle, *end_angle]), - Polyline { points, closed } => { + Polyline { points, closed, .. } => { points.len() >= if *closed { 3 } else { 2 } && points.len() <= BULK_ITEM_CAP && points.iter().all(|p| finite(p) && p[2] == points[0][2]) @@ -44,6 +45,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { ratio, start, end, + .. } => { finite(centre) && direction(major_axis) @@ -56,6 +58,7 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { control_points, knots, weights, + .. } => { *degree > 0 && control_points.len() <= BULK_ITEM_CAP @@ -76,10 +79,12 @@ pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { Ray { origin, direction: d, + .. } | XLine { origin, direction: d, + .. } => finite(origin) && direction(d), }; check(valid, "CreateCurve") @@ -111,6 +116,11 @@ pub(crate) fn operation(op: &Operation) -> ApiResult<()> { } CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(), CreateMText(s) => finite(&s.insertion_point) && positive(s.height), + SetXDataMany(records) => records.iter().all(|(id, app, record)| { + let app_ok = !app.is_empty(); + let record_ok = record.as_ref().map_or(true, |r| !r.application_name.is_empty() && !r.application_name.contains('\0')); + *id != ObjectId::NULL && app_ok && record_ok + }), CreateHatch(s) => { (3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p)) } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 7983d7ad8..112c93e8d 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -8,15 +8,16 @@ use std::sync::Arc; use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Curve2Spec, DocApi, EntityView, GeometryErrorKind, GeometryRevision, - HasId, ObjectId, PlacementSpec, + normalize_layer_name, Aabb, ApiError, ApiResult, Color, Curve2Spec, DocApi, EntityView, + GeometryErrorKind, GeometryRevision, HasId, LayerInfo, LineWeight, ObjectId, PlacementSpec, + XDataRecord, XDataValue, }; /// Coarse bounds for a stored 2D curve spec (mirrors the host's entity_bounds and /// the example mock — all three must answer curve bounds identically). fn curve_bounds(spec: &Curve2Spec) -> Aabb { match spec { - Curve2Spec::Line { start, end } => Aabb { + Curve2Spec::Line { start, end, .. } => Aabb { min: [ start[0].min(end[0]), start[1].min(end[1]), @@ -28,11 +29,12 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { start[2].max(end[2]), ], }, - Curve2Spec::Circle { centre, radius } | Curve2Spec::Arc { centre, radius, .. } => Aabb { + Curve2Spec::Circle { centre, radius, .. } + | Curve2Spec::Arc { centre, radius, .. } => Aabb { min: [centre[0] - radius, centre[1] - radius, centre[2]], max: [centre[0] + radius, centre[1] + radius, centre[2]], }, - Curve2Spec::Point { position } => Aabb { + Curve2Spec::Point { position, .. } => Aabb { min: *position, max: *position, }, @@ -72,13 +74,17 @@ fn curve_bounds(spec: &Curve2Spec) -> Aabb { /// In-memory mock backend: HashMap + a tiny entity table. /// Tracks undo steps + revision bumps to assert per-op semantics. -#[derive(Default, Clone)] +#[derive(Clone)] struct MockBackend { next_id: u64, revision: u64, undo_steps: u32, bodies: HashMap, kinds: HashMap, + /// Layer name assigned to each entity. + entity_layers: HashMap, + /// Stored layer table. + layers: HashMap, /// Stored text content for Text/MText annotations. text_values: HashMap, /// Stored hatch boundary loops. @@ -92,6 +98,36 @@ struct MockBackend { curves: HashMap, /// Stored viewport views (target, height). viewport_views: HashMap, + /// Stored XDATA records keyed by (entity id, application name). + xdata_store: HashMap<(ObjectId, String), ocs_doc_api::XDataRecord>, + /// Stored XRECORD payloads keyed by entity id. + xrecord_store: HashMap, +} + +impl Default for MockBackend { + fn default() -> Self { + let mut backend = Self { + next_id: 0, + revision: 0, + undo_steps: 0, + bodies: HashMap::new(), + kinds: HashMap::new(), + entity_layers: HashMap::new(), + layers: HashMap::new(), + text_values: HashMap::new(), + hatch_boundaries: HashMap::new(), + dimension_measurements: HashMap::new(), + attributes_store: HashMap::new(), + curves: HashMap::new(), + viewport_views: HashMap::new(), + xdata_store: HashMap::new(), + xrecord_store: HashMap::new(), + }; + backend + .layers + .insert("0".to_string(), ocs_doc_api::LayerInfo::new("0")); + backend + } } impl MockBackend { @@ -104,6 +140,22 @@ impl MockBackend { fn body(&self, id: ObjectId) -> ApiResult<&KernelBody> { self.bodies.get(&id).ok_or(ApiError::UnknownId(id)) } + fn add_curve_only(&mut self, spec: &Curve2Spec) -> ApiResult { + let kind = match spec { + Curve2Spec::Line { .. } => "Line", + Curve2Spec::Circle { .. } => "Circle", + Curve2Spec::Polyline { .. } => "LwPolyline", + Curve2Spec::Point { .. } => "Point", + Curve2Spec::Arc { .. } => "Arc", + Curve2Spec::Ellipse { .. } => "Ellipse", + Curve2Spec::Spline { .. } => "Spline", + Curve2Spec::Ray { .. } => "Ray", + Curve2Spec::XLine { .. } => "XLine", + }; + let id = self.alloc(kind); + self.curves.insert(id, spec.clone()); + Ok(id) + } } impl DocApiBackend for MockBackend { @@ -113,6 +165,7 @@ impl DocApiBackend for MockBackend { fn store_solid(&mut self, body: &KernelBody) -> ApiResult { let id = self.alloc("Solid3D"); self.bodies.insert(id, body.clone()); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn update_solid(&mut self, id: ObjectId, body: &KernelBody) -> ApiResult<()> { @@ -122,6 +175,15 @@ impl DocApiBackend for MockBackend { Ok(()) } fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { + // Validate per-spec layers up front so the op is atomic. + for spec in specs { + if let Some(name) = spec.layer() { + self.lookup_layer(name).map_err(|e| ApiError::validation( + "CreateMany", + e.to_string(), + ))?; + } + } let mut next = self.clone(); let ids = specs .iter() @@ -151,26 +213,35 @@ impl DocApiBackend for MockBackend { Ok(()) } fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let kind = match spec { - Curve2Spec::Line { .. } => "Line", - Curve2Spec::Circle { .. } => "Circle", - Curve2Spec::Polyline { .. } => "LwPolyline", - Curve2Spec::Point { .. } => "Point", - Curve2Spec::Arc { .. } => "Arc", - Curve2Spec::Ellipse { .. } => "Ellipse", - Curve2Spec::Spline { .. } => "Spline", - Curve2Spec::Ray { .. } => "Ray", - Curve2Spec::XLine { .. } => "XLine", - }; - let id = self.alloc(kind); - self.curves.insert(id, spec.clone()); + let layer = spec + .layer() + .map(|name| { + self.lookup_layer(name).map(|info| info.normalized_name()) + }) + .transpose()?; + let id = self.add_curve_only(spec)?; + self.entity_layers + .insert(id, layer.unwrap_or_else(|| "0".to_string())); Ok(id) } + + fn lookup_layer(&self, name: &str) -> ApiResult { + let norm = normalize_layer_name(name); + if norm.is_empty() { + return Err(ApiError::validation("LookupLayer", "empty layer name")); + } + self.layers + .get(&norm) + .cloned() + .ok_or_else(|| ApiError::validation("LookupLayer", format!("layer '{name}' does not exist"))) + } fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { if spec.block_name.is_empty() { return Err(ApiError::validation("CreateInsert", "empty block name")); } - Ok(self.alloc("Insert")) + let id = self.alloc("Insert"); + self.entity_layers.insert(id, "0".to_string()); + Ok(id) } fn add_viewport(&mut self, spec: &ocs_doc_api::ops::ViewportSpec) -> ApiResult { let id = self.alloc("Viewport"); @@ -181,11 +252,13 @@ impl DocApiBackend for MockBackend { fn add_text(&mut self, spec: &ocs_doc_api::ops::TextSpec) -> ApiResult { let id = self.alloc("Text"); self.text_values.insert(id, spec.value.clone()); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn add_mtext(&mut self, spec: &ocs_doc_api::ops::MTextSpec) -> ApiResult { let id = self.alloc("MText"); self.text_values.insert(id, spec.value.clone()); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn set_text_content(&mut self, id: ObjectId, value: &str) -> ApiResult<()> { @@ -206,6 +279,7 @@ impl DocApiBackend for MockBackend { let id = self.alloc("Hatch"); self.hatch_boundaries .insert(id, vec![spec.boundary.clone()]); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn hatch_boundary(&self, id: ObjectId) -> ApiResult>> { @@ -224,6 +298,7 @@ impl DocApiBackend for MockBackend { + (spec.first_point[2] - spec.second_point[2]).powi(2)) .sqrt(); self.dimension_measurements.insert(id, d); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn add_dimension_radius( @@ -236,13 +311,16 @@ impl DocApiBackend for MockBackend { + (spec.center[2] - spec.point[2]).powi(2)) .sqrt(); self.dimension_measurements.insert(id, d); + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn add_dimension_diameter( &mut self, spec: &ocs_doc_api::ops::DimensionRadialSpec, ) -> ApiResult { - self.add_dimension_radius(spec) + let id = self.add_dimension_radius(spec)?; + self.entity_layers.insert(id, "0".to_string()); + Ok(id) } fn add_dimension_angular( &mut self, @@ -250,6 +328,7 @@ impl DocApiBackend for MockBackend { ) -> ApiResult { let id = self.alloc("Dimension"); self.dimension_measurements.insert(id, 90.0); // mock: 90 degrees + self.entity_layers.insert(id, "0".to_string()); Ok(id) } fn dimension_measurement(&self, id: ObjectId) -> ApiResult { @@ -286,7 +365,9 @@ impl DocApiBackend for MockBackend { "table needs a non-empty rectangular grid", )); } - Ok(self.alloc("Table")) + let id = self.alloc("Table"); + self.entity_layers.insert(id, "0".to_string()); + Ok(id) } fn set_attribute(&mut self, id: ObjectId, tag: &str, value: &str) -> ApiResult<()> { if !self.entity_exists(id) { @@ -313,7 +394,9 @@ impl DocApiBackend for MockBackend { &mut self, _spec: &ocs_doc_api::ops::RasterImageSpec, ) -> ApiResult { - Ok(self.alloc("RasterImage")) + let id = self.alloc("RasterImage"); + self.entity_layers.insert(id, "0".to_string()); + Ok(id) } fn loft( &mut self, @@ -345,6 +428,203 @@ impl DocApiBackend for MockBackend { .copied() .ok_or(ApiError::UnknownId(id)) } + fn set_xdata( + &mut self, + id: ObjectId, + application_name: &str, + record: Option<&ocs_doc_api::XDataRecord>, + ) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + let key = (id, application_name.to_string()); + match record { + Some(r) => { + self.xdata_store.insert(key, r.clone()); + } + None => { + self.xdata_store.remove(&key); + } + } + Ok(()) + } + fn xdata( + &self, + id: ObjectId, + application_name: &str, + ) -> ApiResult> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + Ok(self + .xdata_store + .get(&(id, application_name.to_string())) + .cloned()) + } + fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { + let id = self.alloc("XRecord"); + self.xrecord_store.insert(id, spec.clone()); + Ok(id) + } + fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { + if !self.object_exists(id) { + return Err(ApiError::validation( + "SetXRecord", + format!("unknown ObjectId {id:?}"), + )); + } + self.xrecord_store.insert(id, spec.clone()); + Ok(()) + } + fn xrecord(&self, id: ObjectId) -> ApiResult> { + if !self.object_exists(id) { + return Err(ApiError::validation( + "GetXRecord", + format!("unknown ObjectId {id:?}"), + )); + } + Ok(self.xrecord_store.get(&id).cloned()) + } + fn create_layer(&mut self, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { + let name = normalize_layer_name(&info.name); + if name.is_empty() { + return Err(ApiError::validation("CreateLayer", "empty layer name")); + } + if self.layers.contains_key(&name) { + return Err(ApiError::validation( + "CreateLayer", + format!("layer '{name}' already exists"), + )); + } + let mut stored = info.clone(); + stored.name = name.clone(); + self.layers.insert(name, stored); + Ok(()) + } + fn update_layer(&mut self, name: &str, info: &ocs_doc_api::LayerInfo) -> ApiResult<()> { + let name_upper = normalize_layer_name(name); + if !self.layers.contains_key(&name_upper) { + return Err(ApiError::validation( + "UpdateLayer", + format!("layer '{name}' does not exist"), + )); + } + let mut stored = info.clone(); + stored.name = name_upper.clone(); + self.layers.insert(name_upper, stored); + Ok(()) + } + fn delete_layer(&mut self, name: &str) -> ApiResult<()> { + let name_upper = normalize_layer_name(name); + if name_upper == "0" { + return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); + } + if self + .entity_layers + .values() + .any(|l| normalize_layer_name(l) == name_upper) + { + return Err(ApiError::validation( + "DeleteLayer", + format!("layer '{name_upper}' still in use"), + )); + } + if self.layers.remove(&name_upper).is_none() { + return Err(ApiError::validation( + "DeleteLayer", + format!("layer '{name}' does not exist"), + )); + } + Ok(()) + } + fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { + if !self.entity_exists(id) { + return Err(ApiError::UnknownId(id)); + } + let layer_upper = normalize_layer_name(layer); + let target = self + .layers + .get(&layer_upper) + .ok_or_else(|| { + ApiError::validation( + "SetEntityLayer", + format!("layer '{layer_upper}' does not exist"), + ) + })?; + if target.flags.locked { + return Err(ApiError::Unsupported(format!( + "entity {id:?} cannot be moved to locked layer '{layer_upper}'" + ))); + } + self.entity_layers.insert(id, layer_upper); + Ok(()) + } + fn layers(&self) -> ApiResult> { + let mut v: Vec<_> = self.layers.values().cloned().collect(); + v.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(v) + } + fn entity_layer(&self, id: ObjectId) -> ApiResult { + self.entity_layers + .get(&id) + .cloned() + .ok_or(ApiError::UnknownId(id)) + } + fn enumerate_entities( + &self, + kind: Option<&str>, + layer: Option<&str>, + include_bounds: bool, + ) -> ApiResult> { + let mut out = Vec::new(); + for (id, kind_name) in &self.kinds { + if let Some(k) = kind { + if !k.eq_ignore_ascii_case(kind_name) { + continue; + } + } + let entity_layer = self + .entity_layers + .get(id) + .cloned() + .unwrap_or_else(|| "0".to_string()); + if let Some(l) = layer { + if !l.eq_ignore_ascii_case(&entity_layer) { + continue; + } + } + let bounds = if include_bounds { + self.curves.get(id).map(curve_bounds) + } else { + None + }; + out.push(EntityView { + id: *id, + kind: kind_name.clone(), + layer: entity_layer, + bounds, + }); + } + Ok(out) + } + fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { + let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; + match spec { + Curve2Spec::Point { position, .. } => Ok(*position), + _ => Err(ApiError::Unsupported( + "GetPointPosition is only for Point entities".into(), + )), + } + } + fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { + let spec = self.curves.get(&id).ok_or(ApiError::UnknownId(id))?; + match spec { + Curve2Spec::Line { start, end, .. } => Ok((*start, *end)), + _ => Err(ApiError::Unsupported( + "GetLineGeometry is only for Line entities".into(), + )), + } + } fn add_vertex(&mut self, id: ObjectId, _at: usize, _point: [f64; 3]) -> ApiResult<()> { if self.entity_exists(id) { Ok(()) @@ -354,6 +634,9 @@ impl DocApiBackend for MockBackend { } fn remove_entity(&mut self, id: ObjectId) -> ApiResult { self.bodies.remove(&id); + self.xdata_store.retain(|(k, _), _| *k != id); + self.xrecord_store.remove(&id); + self.entity_layers.remove(&id); Ok(self.kinds.remove(&id).is_some()) } fn get_entity(&mut self, id: ObjectId) -> ApiResult { @@ -364,7 +647,17 @@ impl DocApiBackend for MockBackend { max: bb.max, }) }); - Ok(EntityView { id, kind, bounds }) + let layer = self + .entity_layers + .get(&id) + .cloned() + .unwrap_or_else(|| "0".to_string()); + Ok(EntityView { + id, + kind, + layer, + bounds, + }) } fn transform_entity(&mut self, id: ObjectId, placement: &PlacementSpec) -> ApiResult<()> { if !self.entity_exists(id) { @@ -412,6 +705,9 @@ impl DocApiBackend for MockBackend { fn entity_exists(&self, id: ObjectId) -> bool { self.kinds.contains_key(&id) } + fn object_exists(&self, id: ObjectId) -> bool { + self.xrecord_store.contains_key(&id) + } fn revision(&self) -> GeometryRevision { GeometryRevision(self.revision) } @@ -609,3 +905,145 @@ fn typed_handles_reject_foreign_sessions_and_tabs() { assert!(group.compensate(&doc).is_err()); assert!(first.document(11).curves().create_point([0.0; 3]).is_err()); } + +#[test] +fn doc_api_layer_crud_via_facade_and_entity_assignment() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + + let mut info = LayerInfo::new("Walls"); + info.color = Color::Index(1); + info.line_weight = LineWeight::Value(25); + doc.create_layer(&info).unwrap(); + + let layers = doc.layers().unwrap(); + assert!(layers.iter().any(|l| l.name == "WALLS")); + + let mut updated = info.clone(); + updated.color = Color::Index(2); + doc.update_layer("walls", &updated).unwrap(); + + let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); + line.set_layer("Walls").unwrap(); + assert_eq!(line.layer().unwrap(), "WALLS"); + + let err = doc.delete_layer("Walls").unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. })); + + let other = LayerInfo::new("Other"); + doc.create_layer(&other).unwrap(); + line.set_layer("Other").unwrap(); + doc.delete_layer("Walls").unwrap(); + assert!(!doc.layers().unwrap().iter().any(|l| l.name == "WALLS")); +} + +#[test] +fn set_xdata_many_is_atomic_and_assigns_records() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); + let circle = doc.curves().create_circle([0.0; 3], 1.0).unwrap(); + + let record1 = XDataRecord { + application_name: "APP1".to_string(), + values: vec![XDataValue::Integer32(42)], + }; + let record2 = XDataRecord { + application_name: "APP2".to_string(), + values: vec![XDataValue::Real(3.14)], + }; + doc.set_xdata_many([ + (line.id(), "APP1", Some(record1.clone())), + (circle.id(), "APP2", Some(record2.clone())), + ]) + .unwrap(); + + let r1 = doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().unwrap(); + assert_eq!(r1.values, vec![XDataValue::Integer32(42)]); + let r2 = doc.entities().get(circle.id()).unwrap().xdata("APP2").unwrap().unwrap(); + assert_eq!(r2.values, vec![XDataValue::Real(3.14)]); +} + +#[test] +fn set_xdata_many_with_unknown_id_fails_atomically() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); + let stale = ObjectId::from_u64(999_999); + + let record = XDataRecord { + application_name: "APP1".to_string(), + values: vec![XDataValue::Integer32(42)], + }; + let err = doc + .set_xdata_many([ + (line.id(), "APP1", Some(record.clone())), + (stale, "APP2", Some(record)), + ]) + .unwrap_err(); + assert!(matches!(err, ApiError::UnknownId(..) | ApiError::Validation { .. })); + assert!(doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().is_none()); +} + +#[test] +fn create_many_applies_per_curve_layer() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + doc.create_layer(&LayerInfo::new("Walls")).unwrap(); + + let ids = doc + .entities() + .create_many(&[ + ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { + start: [0.0; 3], + end: [1.0; 3], + layer: Some("Walls".to_string()), + }), + ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { + position: [2.0; 3], + layer: None, + }), + ]) + .unwrap(); + + assert_eq!(ids[0].layer().unwrap(), "WALLS"); + assert_eq!(ids[1].layer().unwrap(), "0"); +} + +#[test] +fn create_many_rejects_missing_layer_before_mutating() { + let (api, tp) = api(); + let doc = api.document(api.active_tab()); + let rev0 = doc.revision().unwrap().as_u64(); + + let err = doc + .entities() + .create_many(&[ + ocs_doc_api::EntitySpec::Curve(Curve2Spec::Line { + start: [0.0; 3], + end: [1.0; 3], + layer: Some("Missing".to_string()), + }), + ocs_doc_api::EntitySpec::Curve(Curve2Spec::Point { + position: [2.0; 3], + layer: None, + }), + ]) + .unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. })); + + let backend = tp.backend(); + assert_eq!(backend.revision, rev0); + assert_eq!(backend.next_id, 0); +} + +#[test] +fn create_line_on_layer_requires_existing_layer() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + let err = doc + .curves() + .create_line_on_layer([0.0; 3], [1.0; 3], "Missing") + .unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. })); +} diff --git a/crates/ocs_doc_api/tests/spec_wire_consistency.rs b/crates/ocs_doc_api/tests/spec_wire_consistency.rs index f65c3a63f..d7fc6c3dd 100644 --- a/crates/ocs_doc_api/tests/spec_wire_consistency.rs +++ b/crates/ocs_doc_api/tests/spec_wire_consistency.rs @@ -38,7 +38,8 @@ fn spec() -> Spec { /// The variant names of the `Operation` enum, in declaration order. fn operation_variants() -> Vec { use ocs_doc_api::ops::{ - BoolOp, Curve2Spec, InsertSpec, Operation, PlacementSpec, SolidPrimitive, ViewportSpec, + BoolOp, Curve2Spec, InsertSpec, LayerInfo, Operation, PlacementSpec, SolidPrimitive, + ViewportSpec, }; use ocs_doc_api::ObjectId; let _marker: Vec = vec![ @@ -46,7 +47,10 @@ fn operation_variants() -> Vec { centre: [0.0; 3], radius: 1.0, }), - Operation::CreateCurve(Curve2Spec::Point { position: [0.0; 3] }), + Operation::CreateCurve(Curve2Spec::Point { + position: [0.0; 3], + layer: None, + }), Operation::Extrude { profile: ObjectId::from_u64(0), direction: [0.0; 3], @@ -167,6 +171,37 @@ fn operation_variants() -> Vec { second_point: [0.0; 3], arc_location: [0.0; 3], }), + Operation::SetXData { + id: ObjectId::from_u64(0), + application_name: String::new(), + record: None, + }, + Operation::SetXDataMany(vec![]), + Operation::CreateXRecord(ocs_doc_api::ops::XRecordSpec { + name: String::new(), + cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, + entries: vec![], + }), + Operation::SetXRecord { + id: ObjectId::from_u64(0), + spec: ocs_doc_api::ops::XRecordSpec { + name: String::new(), + cloning_flags: ocs_doc_api::ops::XRecordCloningFlags::NotApplicable, + entries: vec![], + }, + }, + Operation::CreateLayer(LayerInfo::new("LAYER")), + Operation::UpdateLayer { + name: String::new(), + info: LayerInfo::new("LAYER"), + }, + Operation::DeleteLayer { + name: String::new(), + }, + Operation::SetEntityLayer { + id: ObjectId::from_u64(0), + layer: String::new(), + }, ]; _marker .iter() @@ -178,7 +213,7 @@ fn operation_variants() -> Vec { index ); let dbg = format!("{:?}", op); - dbg.split(|c: char| c == '(' || c == ' ' || c == '{') + dbg.split(['(', ' ', '{']) .next() .unwrap_or("") .to_string() @@ -226,6 +261,17 @@ fn query_variants() -> Vec { Query::GetViewportView { id: ObjectId::from_u64(0), }, + Query::GetXData { + id: ObjectId::from_u64(0), + application_name: String::new(), + }, + Query::GetXRecord { + id: ObjectId::from_u64(0), + }, + Query::ListLayers, + Query::GetEntityLayer { + id: ObjectId::from_u64(0), + }, ]; marker .iter() @@ -237,7 +283,7 @@ fn query_variants() -> Vec { index ); let dbg = format!("{:?}", q); - dbg.split(|c: char| c == '(' || c == ' ' || c == '{') + dbg.split(['(', ' ', '{']) .next() .unwrap_or("") .to_string() @@ -309,6 +355,14 @@ const OPERATION_BASELINE: &[&str] = &[ "CreateAttributeDefinition", "CreateTable", "CreateDimensionAngular2Ln", + "SetXData", + "SetXDataMany", + "CreateXRecord", + "SetXRecord", + "CreateLayer", + "UpdateLayer", + "DeleteLayer", + "SetEntityLayer", ]; /// Recorded baseline of the `Query` variant order. @@ -325,6 +379,10 @@ const QUERY_BASELINE: &[&str] = &[ "GetAttributes", "GetBlockEntities", "GetViewportView", + "GetXData", + "GetXRecord", + "ListLayers", + "GetEntityLayer", ]; #[test] diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index d749e4a6c..fe23287ed 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -1,19 +1,53 @@ //! Native document API adapter over the existing scene, history and kernel paths. use acadrust::entities::Solid3D; +use acadrust::objects::{Dictionary, ObjectType}; use acadrust::{EntityType, Handle}; use ocs_doc_api::backend::{DocApiBackend, KernelBody}; use ocs_doc_api::{ - Aabb, ApiError, ApiResult, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, - ObjectId, PlacementSpec, + Aabb, ApiError, ApiResult, Color, Curve2Spec, EntityView, GeometryErrorKind, GeometryRevision, + LayerFlags, LayerInfo, LineWeight, ObjectId, PlacementSpec, }; +use crate::scene::annotative::root_named_dict_handle; use crate::scene::convert::acis_export; use crate::scene::model::solid_model; use ocs_doc_api::convert; use super::plugin_host::HostSession; +const XRECORD_DICT_NAME: &str = "OCS_XRECORD_DICT"; + +/// Return the stable named-object dictionary used to hold `XRecord` objects. +/// Created on first use under the root named-objects dictionary and reused +/// thereafter. +fn xrecord_dictionary_handle(doc: &mut acadrust::CadDocument) -> Handle { + let root_h = root_named_dict_handle(doc); + let existing = match doc.objects.get(&root_h) { + Some(ObjectType::Dictionary(root)) => root + .entries + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(XRECORD_DICT_NAME)) + .map(|(_, handle)| *handle), + _ => None, + }; + match existing.filter(|handle| matches!(doc.objects.get(handle), Some(ObjectType::Dictionary(_)))) { + Some(handle) => handle, + None => { + let handle = doc.allocate_handle(); + let mut dict = Dictionary::new(); + dict.handle = handle; + dict.owner = root_h; + doc.objects.insert(handle, ObjectType::Dictionary(dict)); + if let Some(ObjectType::Dictionary(root)) = doc.objects.get_mut(&root_h) { + root.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(XRECORD_DICT_NAME)); + root.add_entry(XRECORD_DICT_NAME, handle); + } + handle + } + } +} + /// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by // the in-process path. Deserializes the envelope, runs the crate executor, // serializes the `Receipt` (or `ApiError`) back to bytes. @@ -63,6 +97,10 @@ fn handle_to_obj(h: Handle) -> ObjectId { ObjectId::from_handle(h) } +fn v3_to_array(v: acadrust::types::Vector3) -> [f64; 3] { + [v.x, v.y, v.z] +} + impl DocApiBackend for HostSession<'_> { fn resolve_body(&mut self, id: ObjectId) -> ApiResult { let handle = obj_to_handle(id); @@ -104,16 +142,32 @@ impl DocApiBackend for HostSession<'_> { } fn create_many(&mut self, specs: &[ocs_doc_api::EntitySpec]) -> ApiResult> { + // Validate per-spec layers up front so the op is atomic. + for spec in specs { + if let Some(name) = spec.layer() { + self.lookup_layer(name).map_err(|e| ApiError::validation( + "CreateMany", + e.to_string(), + ))?; + } + } let mut prepared = Vec::with_capacity(specs.len()); for spec in specs { prepared.push(match spec { ocs_doc_api::EntitySpec::Solid(spec) => { self.prepare_doc_solid(ocs_doc_api::geom::make_solid(spec)?, None)? } - ocs_doc_api::EntitySpec::Curve(spec) => PreparedDocEntity { - entity: convert::curve_spec_to_entity(spec)?, - solid: None, - }, + ocs_doc_api::EntitySpec::Curve(spec) => { + let mut entity = convert::curve_spec_to_entity(spec)?; + if let Some(name) = spec.layer() { + entity.common_mut().layer = + acadrust::tables::normalize_name(name.trim()); + } + PreparedDocEntity { + entity, + solid: None, + } + } }); } DocApiBackend::push_undo(self, "CreateMany"); @@ -139,11 +193,38 @@ impl DocApiBackend for HostSession<'_> { } fn add_curve(&mut self, spec: &Curve2Spec) -> ApiResult { - let entity = convert::curve_spec_to_entity(spec)?; + let layer_name = spec.layer(); + if let Some(name) = layer_name { + self.lookup_layer(name).map_err(|e| ApiError::validation( + "CreateCurve", + e.to_string(), + ))?; + } + let mut entity = convert::curve_spec_to_entity(spec)?; + if let Some(name) = layer_name { + entity.common_mut().layer = acadrust::tables::normalize_name(name.trim()); + } let handle = self.scene_mut().add_entity(entity); Ok(handle_to_obj(handle)) } + fn lookup_layer(&self, name: &str) -> ApiResult { + let norm = acadrust::tables::normalize_name(name.trim()); + if norm.is_empty() { + return Err(ApiError::validation("LookupLayer", "empty layer name")); + } + self.document() + .layers + .get(&norm) + .map(|l| layer_info_from_acadrust(l)) + .ok_or_else(|| { + ApiError::validation( + "LookupLayer", + format!("layer '{name}' does not exist"), + ) + }) + } + fn add_insert(&mut self, spec: &ocs_doc_api::ops::InsertSpec) -> ApiResult { // Validate the referenced block exists before committing. if self @@ -422,17 +503,19 @@ impl DocApiBackend for HostSession<'_> { ) -> ApiResult { use acadrust::entities::AttributeDefinition; use acadrust::types::Vector3; - let mut att = AttributeDefinition::default(); - att.tag = spec.tag.clone(); - att.prompt = spec.prompt.clone(); - att.default_value = spec.default_value.clone(); - att.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); - att.height = spec.height; - att.rotation = spec.rotation; + let att = AttributeDefinition { + tag: spec.tag.clone(), + prompt: spec.prompt.clone(), + default_value: spec.default_value.clone(), + insertion_point: Vector3::new( + spec.insertion_point[0], + spec.insertion_point[1], + spec.insertion_point[2], + ), + height: spec.height, + rotation: spec.rotation, + ..Default::default() + }; let handle = self .scene_mut() .add_entity(EntityType::AttributeDefinition(att)); @@ -493,10 +576,12 @@ impl DocApiBackend for HostSession<'_> { if let Some(attr) = ins.attributes.iter_mut().find(|a| a.tag == tag) { attr.value = value.to_string(); } else { - let mut attr = acadrust::entities::AttributeEntity::default(); - attr.tag = tag.to_string(); - attr.value = value.to_string(); - attr.insertion_point = ins.insert_point; + let attr = acadrust::entities::AttributeEntity { + tag: tag.to_string(), + value: value.to_string(), + insertion_point: ins.insert_point, + ..Default::default() + }; ins.attributes.push(attr); } if !self.scene_mut().update_entity(EntityType::Insert(ins)) { @@ -543,7 +628,16 @@ impl DocApiBackend for HostSession<'_> { ), None => ("Missing".to_string(), None), }; - out.push(EntityView { id, kind, bounds }); + let layer = match self.document().get_entity(*h) { + Some(e) => e.common().layer.clone(), + None => String::new(), + }; + out.push(EntityView { + id, + kind, + layer, + bounds, + }); } Ok(out) } @@ -602,16 +696,18 @@ impl DocApiBackend for HostSession<'_> { // non-empty, a known image extension, and not a UNC/network path or // parent-traversal (which could point the host at unintended resources). validate_image_path(&spec.file_path)?; - let mut img = RasterImage::default(); - img.file_path = spec.file_path.clone(); - img.insertion_point = Vector3::new( - spec.insertion_point[0], - spec.insertion_point[1], - spec.insertion_point[2], - ); - img.u_vector = Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]); - img.v_vector = Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]); - img.size = Vector2::new(spec.size[0], spec.size[1]); + let img = RasterImage { + file_path: spec.file_path.clone(), + insertion_point: Vector3::new( + spec.insertion_point[0], + spec.insertion_point[1], + spec.insertion_point[2], + ), + u_vector: Vector3::new(spec.u_vector[0], spec.u_vector[1], spec.u_vector[2]), + v_vector: Vector3::new(spec.v_vector[0], spec.v_vector[1], spec.v_vector[2]), + size: Vector2::new(spec.size[0], spec.size[1]), + ..Default::default() + }; // add_entity auto-registers the ImageDefinition (scene/entity.rs). let handle = self.scene_mut().add_entity(EntityType::RasterImage(img)); Ok(handle_to_obj(handle)) @@ -626,6 +722,338 @@ impl DocApiBackend for HostSession<'_> { self.store_solid(&body) } + fn set_xdata( + &mut self, + id: ObjectId, + application_name: &str, + record: Option<&ocs_doc_api::XDataRecord>, + ) -> ApiResult<()> { + self.can_modify(id)?; + let handle = obj_to_handle(id); + let values = record.map(|r| { + r.values + .iter() + .map(|v| xdata_value_to_acadrust(v, self.document())) + .collect::>() + }); + crate::scene::view::dispatch::set_entity_xdata( + self.document_mut(), + handle, + application_name, + values, + ); + Ok(()) + } + + fn xdata(&self, id: ObjectId, application_name: &str) -> ApiResult> { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + let Some(rec) = entity.common().extended_data.get_record(application_name) else { + return Ok(None); + }; + Ok(Some(ocs_doc_api::XDataRecord { + application_name: rec.application_name.clone(), + values: rec + .values + .iter() + .map(xdata_value_from_acadrust) + .collect(), + })) + } + + fn object_exists(&self, id: ObjectId) -> bool { + let handle = obj_to_handle(id); + self.document().objects.contains_key(&handle) + } + + fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { + use acadrust::objects::{ObjectType, XRecord}; + let mut record = XRecord::named(spec.name.clone()); + record.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); + record.entries = spec + .entries + .iter() + .map(xrecord_entry_to_acadrust) + .collect(); + record.synchronize_object_references(); + let record_handle = { + let doc = self.document_mut(); + let handle = doc.allocate_handle(); + record.handle = handle; + doc.objects + .insert(handle, ObjectType::XRecord(record)); + handle + }; + // Attach the new XRecord to the stable named-object dictionary so it is + // reachable as a standalone named object. + let dict_h = xrecord_dictionary_handle(self.document_mut()); + if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { + dict.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(&spec.name)); + dict.add_entry(spec.name.clone(), record_handle); + } + Ok(handle_to_obj(record_handle)) + } + + fn set_xrecord(&mut self, id: ObjectId, spec: &ocs_doc_api::XRecordSpec) -> ApiResult<()> { + if !self.object_exists(id) { + return Err(ApiError::validation( + "SetXRecord", + format!("unknown ObjectId {id:?}"), + )); + } + let handle = obj_to_handle(id); + let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { + return Err(ApiError::Unsupported(format!("ObjectId {id:?} is not an XRecord"))); + }; + let old_name = record.name.clone(); + let mut updated = record.clone(); + updated.name = spec.name.clone(); + updated.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); + updated.entries = spec + .entries + .iter() + .map(xrecord_entry_to_acadrust) + .collect(); + updated.synchronize_object_references(); + self.document_mut() + .objects + .insert(handle, acadrust::objects::ObjectType::XRecord(updated)); + // If the name changed, rename the dictionary entry so the XRecord remains + // reachable under its new name. + if !old_name.eq_ignore_ascii_case(&spec.name) { + let dict_h = xrecord_dictionary_handle(self.document_mut()); + if let Some(ObjectType::Dictionary(dict)) = + self.document_mut().objects.get_mut(&dict_h) + { + dict.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(&old_name)); + dict.add_entry(spec.name.clone(), handle); + } + } + Ok(()) + } + + fn xrecord(&self, id: ObjectId) -> ApiResult> { + let handle = obj_to_handle(id); + let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { + return Ok(None); + }; + Ok(Some(ocs_doc_api::XRecordSpec { + name: record.name.clone(), + cloning_flags: cloning_flags_from_acadrust(record.cloning_flags), + entries: record + .entries + .iter() + .map(xrecord_entry_from_acadrust) + .collect(), + })) + } + + fn create_layer(&mut self, info: &LayerInfo) -> ApiResult<()> { + let name = acadrust::tables::normalize_name(info.name.trim()); + if name.is_empty() { + return Err(ApiError::validation("CreateLayer", "empty layer name")); + } + if self.document().layers.contains(&name) { + return Err(ApiError::validation( + "CreateLayer", + format!("layer '{name}' already exists"), + )); + } + let mut layer = acadrust::tables::Layer::new(&name); + layer.handle = self.document_mut().allocate_handle(); + apply_layer_info(&mut layer, info); + self.document_mut() + .layers + .add(layer) + .map_err(|e| ApiError::validation("CreateLayer", e))?; + Ok(()) + } + + fn update_layer(&mut self, name: &str, info: &LayerInfo) -> ApiResult<()> { + let deps = { + let doc = self.document_mut(); + let name_upper = acadrust::tables::normalize_name(name.trim()); + let Some(existing) = doc.layers.get(&name_upper).cloned() else { + return Err(ApiError::validation( + "UpdateLayer", + format!("layer '{name}' does not exist"), + )); + }; + let new_name = acadrust::tables::normalize_name(info.name.trim()); + if new_name.is_empty() { + return Err(ApiError::validation("UpdateLayer", "empty layer name")); + } + // Rename if the key changed (preserves handle and table position). + if name_upper != new_name { + doc.layers + .rename(&name_upper, new_name.clone()) + .map_err(|e| ApiError::validation("UpdateLayer", e))?; + } + let Some(layer) = doc.layers.get_mut(&new_name) else { + return Err(ApiError::validation( + "UpdateLayer", + format!("layer '{name}' does not exist"), + )); + }; + apply_layer_info(layer, info); + [existing.name.clone(), layer.name.clone()] + }; + // By-layer appearance changed: rebuild derived geometry that resolved it. + self.scene_mut().invalidate_layer_dependencies(&deps); + Ok(()) + } + fn delete_layer(&mut self, name: &str) -> ApiResult<()> { + let name_upper = acadrust::tables::normalize_name(name); + if name_upper == "0" { + return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); + } + if !self.document().layers.contains(&name_upper) { + return Err(ApiError::validation( + "DeleteLayer", + format!("layer '{name}' does not exist"), + )); + } + if acadrust::tables::normalize_name(&self.document().header.current_layer_name) == name_upper + { + return Err(ApiError::validation( + "DeleteLayer", + "cannot delete the current layer", + )); + } + if self + .document() + .entities() + .any(|e| acadrust::tables::normalize_name(&e.common().layer) == name_upper) + { + return Err(ApiError::validation( + "DeleteLayer", + format!("layer '{name}' is still in use"), + )); + } + // Remove by the normalized name so a differently-cased request behaves + // consistently with the rest of the layer table API. + self.document_mut().layers.remove(&name_upper); + Ok(()) + } + + + fn set_entity_layer(&mut self, id: ObjectId, layer: &str) -> ApiResult<()> { + self.can_modify(id)?; + let handle = obj_to_handle(id); + let layer_upper = acadrust::tables::normalize_name(layer.trim()); + let target = self.document().layers.get(&layer_upper).cloned(); + let Some(target) = target else { + return Err(ApiError::validation( + "SetEntityLayer", + format!("layer '{layer}' does not exist"), + )); + }; + if target.is_locked() { + return Err(ApiError::Unsupported(format!( + "entity {id:?} cannot be moved to locked layer '{layer}'" + ))); + } + let mut entity = self + .document() + .get_entity(handle) + .cloned() + .ok_or(ApiError::UnknownId(id))?; + entity.common_mut().layer = target.name.clone(); + if !self.scene_mut().update_entity(entity) { + return Err(ApiError::Unsupported(format!( + "entity {id:?} is on a locked layer" + ))); + } + Ok(()) + } + + fn layers(&self) -> ApiResult> { + let mut v: Vec<_> = self + .document() + .layers + .iter() + .map(layer_info_from_acadrust) + .collect(); + v.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(v) + } + + fn entity_layer(&self, id: ObjectId) -> ApiResult { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + Ok(entity.common().layer.clone()) + } + + fn enumerate_entities( + &self, + kind: Option<&str>, + layer: Option<&str>, + include_bounds: bool, + ) -> ApiResult> { + let doc = self.document(); + let mut out = Vec::new(); + for entity in doc.entities() { + let handle = entity.common().handle; + let id = handle_to_obj(handle); + let entity_kind = convert::entity_kind_name(entity); + if let Some(k) = kind { + if !entity_kind.eq_ignore_ascii_case(k) { + continue; + } + } + if let Some(l) = layer { + if !entity.common().layer.eq_ignore_ascii_case(l) { + continue; + } + } + out.push(EntityView { + id, + kind: entity_kind.to_string(), + layer: entity.common().layer.clone(), + bounds: if include_bounds { + convert::entity_bounds(Some(entity), id).ok() + } else { + None + }, + }); + } + Ok(out) + } + + fn point_position(&self, id: ObjectId) -> ApiResult<[f64; 3]> { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + let EntityType::Point(pt) = entity else { + return Err(ApiError::Unsupported( + "GetPointPosition is only for Point entities".into(), + )); + }; + Ok(v3_to_array(pt.location)) + } + + fn line_geometry(&self, id: ObjectId) -> ApiResult<([f64; 3], [f64; 3])> { + let handle = obj_to_handle(id); + let entity = self + .document() + .get_entity(handle) + .ok_or(ApiError::UnknownId(id))?; + let EntityType::Line(line) = entity else { + return Err(ApiError::Unsupported( + "GetLineGeometry is only for Line entities".into(), + )); + }; + Ok((v3_to_array(line.start), v3_to_array(line.end))) + } + fn add_vertex(&mut self, id: ObjectId, at: usize, point: [f64; 3]) -> ApiResult<()> { let handle = obj_to_handle(id); let Some(entity) = self.document().get_entity(handle).cloned() else { @@ -746,6 +1174,7 @@ impl DocApiBackend for HostSession<'_> { Ok(EntityView { id, kind: convert::entity_kind_name(entity).to_string(), + layer: entity.common().layer.clone(), bounds: self.bounds(id).ok(), }) } @@ -831,16 +1260,15 @@ impl DocApiBackend for HostSession<'_> { // ── helpers ────────────────────────────────────────────────────────────────── +type SolidDisplay = ( + crate::scene::model::mesh_model::MeshLodSet, + Vec, + [f64; 3], +); + struct PreparedDocEntity { entity: EntityType, - solid: Option<( - KernelBody, - ( - crate::scene::model::mesh_model::MeshLodSet, - Vec, - [f64; 3], - ), - )>, + solid: Option<(KernelBody, SolidDisplay)>, } impl HostSession<'_> { @@ -999,12 +1427,228 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement { } } +// Conversions between ocs_doc_api plain-data DTOs and acadrust types. +// Kept in this file because they are host-side implementation details. + +fn cloning_flags_to_acadrust( + flags: ocs_doc_api::XRecordCloningFlags, +) -> acadrust::objects::DictionaryCloningFlags { + use acadrust::objects::DictionaryCloningFlags as F; + match flags { + ocs_doc_api::XRecordCloningFlags::NotApplicable => F::NotApplicable, + ocs_doc_api::XRecordCloningFlags::KeepExisting => F::KeepExisting, + ocs_doc_api::XRecordCloningFlags::UseClone => F::UseClone, + ocs_doc_api::XRecordCloningFlags::XrefName => F::XrefName, + ocs_doc_api::XRecordCloningFlags::Name => F::Name, + ocs_doc_api::XRecordCloningFlags::UnmangleName => F::UnmangleName, + } +} + +fn cloning_flags_from_acadrust( + flags: acadrust::objects::DictionaryCloningFlags, +) -> ocs_doc_api::XRecordCloningFlags { + use acadrust::objects::DictionaryCloningFlags as F; + match flags { + F::NotApplicable => ocs_doc_api::XRecordCloningFlags::NotApplicable, + F::KeepExisting => ocs_doc_api::XRecordCloningFlags::KeepExisting, + F::UseClone => ocs_doc_api::XRecordCloningFlags::UseClone, + F::XrefName => ocs_doc_api::XRecordCloningFlags::XrefName, + F::Name => ocs_doc_api::XRecordCloningFlags::Name, + F::UnmangleName => ocs_doc_api::XRecordCloningFlags::UnmangleName, + } +} + +fn xdata_value_to_acadrust( + value: &ocs_doc_api::XDataValue, + _doc: &acadrust::CadDocument, +) -> acadrust::xdata::XDataValue { + use acadrust::types::Vector3; + use ocs_doc_api::XDataValue as V; + match value { + V::String(s) => acadrust::xdata::XDataValue::String(s.clone()), + V::ControlString(s) => acadrust::xdata::XDataValue::ControlString(s.clone()), + V::LayerName(s) => acadrust::xdata::XDataValue::LayerName(s.clone()), + V::BinaryData(b) => acadrust::xdata::XDataValue::BinaryData(b.clone()), + V::Handle(h) => acadrust::xdata::XDataValue::Handle(acadrust::Handle::new(*h)), + V::Point3D(p) => acadrust::xdata::XDataValue::Point3D(Vector3::new(p[0], p[1], p[2])), + V::Position3D(p) => acadrust::xdata::XDataValue::Position3D(Vector3::new(p[0], p[1], p[2])), + V::Displacement3D(p) => { + acadrust::xdata::XDataValue::Displacement3D(Vector3::new(p[0], p[1], p[2])) + } + V::Direction3D(p) => acadrust::xdata::XDataValue::Direction3D(Vector3::new(p[0], p[1], p[2])), + V::Real(r) => acadrust::xdata::XDataValue::Real(*r), + V::Distance(d) => acadrust::xdata::XDataValue::Distance(*d), + V::ScaleFactor(s) => acadrust::xdata::XDataValue::ScaleFactor(*s), + V::Integer16(i) => acadrust::xdata::XDataValue::Integer16(*i), + V::Integer32(i) => acadrust::xdata::XDataValue::Integer32(*i), + } +} + +fn xdata_value_from_acadrust(value: &acadrust::xdata::XDataValue) -> ocs_doc_api::XDataValue { + use acadrust::xdata::XDataValue as V; + match value { + V::String(s) => ocs_doc_api::XDataValue::String(s.clone()), + V::ControlString(s) => ocs_doc_api::XDataValue::ControlString(s.clone()), + V::LayerName(s) => ocs_doc_api::XDataValue::LayerName(s.clone()), + V::BinaryData(b) => ocs_doc_api::XDataValue::BinaryData(b.clone()), + V::Handle(h) => ocs_doc_api::XDataValue::Handle(h.value()), + V::Point3D(v) => ocs_doc_api::XDataValue::Point3D([v.x, v.y, v.z]), + V::Position3D(v) => ocs_doc_api::XDataValue::Position3D([v.x, v.y, v.z]), + V::Displacement3D(v) => ocs_doc_api::XDataValue::Displacement3D([v.x, v.y, v.z]), + V::Direction3D(v) => ocs_doc_api::XDataValue::Direction3D([v.x, v.y, v.z]), + V::Real(r) => ocs_doc_api::XDataValue::Real(*r), + V::Distance(d) => ocs_doc_api::XDataValue::Distance(*d), + V::ScaleFactor(s) => ocs_doc_api::XDataValue::ScaleFactor(*s), + V::Integer16(i) => ocs_doc_api::XDataValue::Integer16(*i), + V::Integer32(i) => ocs_doc_api::XDataValue::Integer32(*i), + } +} + +fn xrecord_value_to_acadrust(value: &ocs_doc_api::XRecordValue) -> acadrust::objects::XRecordValue { + use ocs_doc_api::XRecordValue as V; + match value { + V::String(s) => acadrust::objects::XRecordValue::String(s.clone()), + V::Double(d) => acadrust::objects::XRecordValue::Double(*d), + V::Int16(i) => acadrust::objects::XRecordValue::Int16(*i), + V::Int32(i) => acadrust::objects::XRecordValue::Int32(*i), + V::Int64(i) => acadrust::objects::XRecordValue::Int64(*i), + V::Byte(b) => acadrust::objects::XRecordValue::Byte(*b), + V::Bool(b) => acadrust::objects::XRecordValue::Bool(*b), + V::Handle(h) => acadrust::objects::XRecordValue::Handle(acadrust::Handle::new(*h)), + V::Point3D(p) => acadrust::objects::XRecordValue::Point3D(p[0], p[1], p[2]), + V::Chunk(c) => acadrust::objects::XRecordValue::Chunk(c.clone()), + } +} + +fn xrecord_value_from_acadrust( + value: &acadrust::objects::XRecordValue, +) -> ocs_doc_api::XRecordValue { + use acadrust::objects::XRecordValue as V; + match value { + V::String(s) => ocs_doc_api::XRecordValue::String(s.clone()), + V::Double(d) => ocs_doc_api::XRecordValue::Double(*d), + V::Int16(i) => ocs_doc_api::XRecordValue::Int16(*i), + V::Int32(i) => ocs_doc_api::XRecordValue::Int32(*i), + V::Int64(i) => ocs_doc_api::XRecordValue::Int64(*i), + V::Byte(b) => ocs_doc_api::XRecordValue::Byte(*b), + V::Bool(b) => ocs_doc_api::XRecordValue::Bool(*b), + V::Handle(h) => ocs_doc_api::XRecordValue::Handle(h.value()), + V::Point3D(x, y, z) => ocs_doc_api::XRecordValue::Point3D([*x, *y, *z]), + V::Chunk(c) => ocs_doc_api::XRecordValue::Chunk(c.clone()), + } +} + +fn xrecord_entry_to_acadrust( + entry: &ocs_doc_api::XRecordEntry, +) -> acadrust::objects::XRecordEntry { + acadrust::objects::XRecordEntry { + code: entry.code, + value: xrecord_value_to_acadrust(&entry.value), + } +} + +fn xrecord_entry_from_acadrust( + entry: &acadrust::objects::XRecordEntry, +) -> ocs_doc_api::XRecordEntry { + ocs_doc_api::XRecordEntry { + code: entry.code, + value: xrecord_value_from_acadrust(&entry.value), + } +} + +// ── layer conversions ───────────────────────────────────────────────────── + +fn color_to_acadrust(c: Color) -> acadrust::types::Color { + use acadrust::types::Color as A; + match c { + Color::ByLayer => A::ByLayer, + Color::None => A::None, + Color::ByBlock => A::ByBlock, + Color::Index(i) => A::Index(i), + Color::Rgb { r, g, b } => A::Rgb { r, g, b }, + } +} + +fn color_from_acadrust(c: acadrust::types::Color) -> Color { + use acadrust::types::Color as A; + match c { + A::ByLayer => Color::ByLayer, + A::None => Color::None, + A::ByBlock => Color::ByBlock, + A::Index(i) => Color::Index(i), + A::Rgb { r, g, b } => Color::Rgb { r, g, b }, + } +} + +fn line_weight_to_acadrust(lw: LineWeight) -> acadrust::types::LineWeight { + use acadrust::types::LineWeight as A; + match lw { + LineWeight::ByLayer => A::ByLayer, + LineWeight::ByBlock => A::ByBlock, + LineWeight::Default => A::Default, + LineWeight::Value(v) => A::Value(v), + } +} + +fn line_weight_from_acadrust(lw: acadrust::types::LineWeight) -> LineWeight { + use acadrust::types::LineWeight as A; + match lw { + A::ByLayer => LineWeight::ByLayer, + A::ByBlock => LineWeight::ByBlock, + A::Default => LineWeight::Default, + A::Value(v) => LineWeight::Value(v), + } +} + +fn layer_flags_to_acadrust(f: LayerFlags) -> acadrust::tables::LayerFlags { + acadrust::tables::LayerFlags { + frozen: f.frozen, + locked: f.locked, + frozen_in_new_viewport: f.frozen_in_new_viewport, + off: f.off, + xref_dependent: false, + } +} + +fn layer_flags_from_acadrust(f: acadrust::tables::LayerFlags) -> LayerFlags { + LayerFlags { + frozen: f.frozen, + locked: f.locked, + frozen_in_new_viewport: f.frozen_in_new_viewport, + off: f.off, + } +} + +fn apply_layer_info(layer: &mut acadrust::tables::Layer, info: &LayerInfo) { + layer.name = info.name.trim().to_string(); + layer.flags = layer_flags_to_acadrust(info.flags); + layer.color = color_to_acadrust(info.color); + layer.color_name = None; + layer.book_name = None; + layer.line_type = info.line_type.clone(); + layer.line_weight = line_weight_to_acadrust(info.line_weight); + layer.plot_style = info.plot_style.clone(); + layer.is_plottable = info.is_plottable; +} + +fn layer_info_from_acadrust(layer: &acadrust::tables::Layer) -> LayerInfo { + LayerInfo { + name: layer.name.clone(), + flags: layer_flags_from_acadrust(layer.flags), + color: color_from_acadrust(layer.color), + line_type: layer.line_type.clone(), + line_weight: line_weight_from_acadrust(layer.line_weight), + plot_style: layer.plot_style.clone(), + is_plottable: layer.is_plottable, + } +} + #[cfg(test)] mod tests { use super::*; use crate::app::plugin_host::HostSession; use crate::app::OpenCADStudio; - use ocs_doc_api::ops::{BoolOp, SolidPrimitive}; + use ocs_doc_api::ops::{BoolOp, Color, LayerFlags, LayerInfo, LineWeight, SolidPrimitive}; use ocs_doc_api::{DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt}; fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { @@ -1139,7 +1783,7 @@ mod tests { fn phase0_add_vertex_to_polyline() { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); - let mk_poly = Operation::CreateCurve(Curve2Spec::Polyline { + let mk_poly = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], closed: false, }); @@ -1179,7 +1823,7 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); // A closed rectangular 2x3 profile in XY. - let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 0.0], [2.0, 0.0, 0.0], @@ -1217,7 +1861,7 @@ mod tests { let mut host = HostSession::new(&mut app, 0); // A 1-wide, 2-tall rectangle offset 1 from the Y axis; revolve about the Y // axis by 2*pi -> a cylinder-ish annulus (outer r=2, inner r=1, h=2): pi*(4-1)*2 = 6pi ≈ 18.85. - let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { + let mk_profile = Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [1.0, 0.0, 0.0], [2.0, 0.0, 0.0], @@ -1259,7 +1903,7 @@ mod tests { fn phase0_non_solid_transform_line_and_circle() { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); - let mk_line = Operation::CreateCurve(Curve2Spec::Line { + let mk_line = Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [10.0; 3], }); @@ -1291,7 +1935,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0; 3], radius: 2.0, })), @@ -1319,7 +1963,7 @@ mod tests { let arc = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, centre: [5.0, 5.0, 0.0], radius: 4.0, start_angle: 0.0, @@ -1352,7 +1996,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, centre: [0.0, 0.0, 0.0], major_axis: [6.0, 0.0, 0.0], ratio: 0.5, @@ -1377,7 +2021,7 @@ mod tests { let spline = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Spline { layer: None, degree: 3, control_points: vec![ [0.0, 0.0, 0.0], @@ -1429,7 +2073,7 @@ mod tests { let ray = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, origin: [1.0, 2.0, 0.0], direction: [1.0, 0.0, 0.0], })), @@ -1439,7 +2083,7 @@ mod tests { let xline = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::XLine { layer: None, origin: [0.0, 0.0, 0.0], direction: [0.0, 1.0, 0.0], })), @@ -1561,7 +2205,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 0.0], radius: 1.0, })), @@ -1609,7 +2253,7 @@ mod tests { let ray = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ray { layer: None, origin: [0.0, 0.0, 0.0], direction: [1.0, 0.0, 0.0], })), @@ -1637,7 +2281,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, @@ -1668,7 +2312,7 @@ mod tests { let arc = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Arc { layer: None, centre: [0.0; 3], radius: 2.0, start_angle: 0.0, @@ -1704,7 +2348,7 @@ mod tests { let a = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [0.0; 3], })), ) @@ -1713,7 +2357,7 @@ mod tests { let b = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [1.0; 3], })), ) @@ -1808,7 +2452,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [1.0; 3], })), @@ -1869,7 +2513,7 @@ mod tests { let c1 = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 0.0], radius: 5.0, })), @@ -1879,7 +2523,7 @@ mod tests { let c2 = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0, 0.0, 10.0], radius: 2.0, })), @@ -1920,7 +2564,7 @@ mod tests { let poly = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]], closed: false, })), @@ -1967,7 +2611,7 @@ mod tests { if let cadkernel::geom2d::Curve::Arc(arc) = &neg { // 90° arc, chord 1: radius = 0.5/sin(45°) ≈ 0.7071; center y must be negative. assert!( - (arc.radius - 0.7071).abs() < 0.01, + (arc.radius - std::f64::consts::FRAC_1_SQRT_2).abs() < 0.01, "cw arc radius {}", arc.radius ); @@ -2286,8 +2930,8 @@ mod tests { let rev_before = host.scene().geometry_epoch; // A valid point + an invalid ellipse (ratio > 1) in one CreateMany batch. let specs = vec![ - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }), - ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { + ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), + ocs_doc_api::ops::EntitySpec::Curve(Curve2Spec::Ellipse { layer: None, centre: [0.0; 3], major_axis: [1.0; 3], ratio: 2.0, @@ -2314,7 +2958,7 @@ mod tests { let ell = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Ellipse { layer: None, centre: [0.0; 3], major_axis: [4.0, 0.0, 0.0], ratio: 0.5, @@ -2392,24 +3036,28 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); // Leader with vertices at (0,0,0),(5,5,0),(10,0,0) -> bounds x[0,10] y[0,5]. - let mut leader = acadrust::entities::Leader::default(); - leader.vertices = vec![ - acadrust::types::Vector3::new(0.0, 0.0, 0.0), - acadrust::types::Vector3::new(5.0, 5.0, 0.0), - acadrust::types::Vector3::new(10.0, 0.0, 0.0), - ]; + let leader = acadrust::entities::Leader { + vertices: vec![ + acadrust::types::Vector3::new(0.0, 0.0, 0.0), + acadrust::types::Vector3::new(5.0, 5.0, 0.0), + acadrust::types::Vector3::new(10.0, 0.0, 0.0), + ], + ..Default::default() + }; let leader_h = host .document_mut() .add_entity(EntityType::Leader(leader)) .unwrap(); // Mesh with vertices in a unit cube at (2..3, 2..3, 0). - let mut mesh = acadrust::entities::Mesh::default(); - mesh.vertices = vec![ - acadrust::types::Vector3::new(2.0, 2.0, 0.0), - acadrust::types::Vector3::new(3.0, 2.0, 0.0), - acadrust::types::Vector3::new(3.0, 3.0, 0.0), - acadrust::types::Vector3::new(2.0, 3.0, 0.0), - ]; + let mesh = acadrust::entities::Mesh { + vertices: vec![ + acadrust::types::Vector3::new(2.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 2.0, 0.0), + acadrust::types::Vector3::new(3.0, 3.0, 0.0), + acadrust::types::Vector3::new(2.0, 3.0, 0.0), + ], + ..Default::default() + }; let mesh_h = host .document_mut() .add_entity(EntityType::Mesh(mesh)) @@ -2562,7 +3210,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [1.0; 3], })), @@ -2594,7 +3242,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0; 3], end: [10.0; 3], })), @@ -2811,11 +3459,13 @@ mod tests { let img = { use acadrust::entities::RasterImage; use acadrust::types::{Vector2, Vector3}; - let mut img = RasterImage::default(); - img.insertion_point = Vector3::new(10.0, 20.0, 0.0); - img.u_vector = Vector3::new(0.5, 0.0, 0.0); // 0.5 world-units/pixel in X - img.v_vector = Vector3::new(0.0, 0.5, 0.0); - img.size = Vector2::new(100.0, 50.0); + let img = RasterImage { + insertion_point: Vector3::new(10.0, 20.0, 0.0), + u_vector: Vector3::new(0.5, 0.0, 0.0), // 0.5 world-units/pixel in X + v_vector: Vector3::new(0.0, 0.5, 0.0), + size: Vector2::new(100.0, 50.0), + ..Default::default() + }; host.document_mut() .add_entity(EntityType::RasterImage(img)) .unwrap() @@ -2927,7 +3577,7 @@ mod tests { let mut app = OpenCADStudio::new_for_test(); let mut host = HostSession::new(&mut app, 0); // A request naming a DIFFERENT tab than the bound one is rejected. - let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + let env = DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [0.0; 3], })); let bytes = bincode::serialize(&env).unwrap(); @@ -2967,7 +3617,7 @@ mod tests { let line = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Line { layer: None, start: [0.0, 0.0, 0.0], end: [10.0, 0.0, 0.0], })), @@ -3000,7 +3650,7 @@ mod tests { let circle = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [5.0, 5.0, 0.0], radius: 3.0, })), @@ -3026,7 +3676,7 @@ mod tests { let point = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Point { layer: None, position: [1.0, 2.0, 3.0], })), ) @@ -3046,7 +3696,7 @@ mod tests { let poly = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], @@ -3247,17 +3897,17 @@ mod tests { let mut host = HostSession::new(&mut app, 0); let invalid_ops = vec![ Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { position: [0.0; 3] }), + EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [0.0; 3] }), EntitySpec::Solid(SolidPrimitive::Sphere { centre: [0.0; 3], radius: f64::NAN, }), ]), - Operation::CreateCurve(Curve2Spec::Circle { + Operation::CreateCurve(Curve2Spec::Circle { layer: None, centre: [0.0; 3], radius: -1.0, }), - Operation::CreateCurve(Curve2Spec::Polyline { + Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![[0.0; 3], [1.0; 3]], closed: false, }), @@ -3290,7 +3940,7 @@ mod tests { let receipt = dispatch( &mut host, DocApiEnvelope::op(Operation::CreateMany(vec![ - EntitySpec::Curve(Curve2Spec::Point { position: [1.0; 3] }), + EntitySpec::Curve(Curve2Spec::Point { layer: None, position: [1.0; 3] }), EntitySpec::Solid(SolidPrimitive::Cuboid { origin: [0.0; 3], size: [2.0; 3], @@ -3360,7 +4010,7 @@ mod tests { let profile = new_id( &dispatch( &mut host, - DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { + DocApiEnvelope::op(Operation::CreateCurve(Curve2Spec::Polyline { layer: None, points: vec![ [0.0, 0.0, 7.0], [2.0, 0.0, 7.0], @@ -3473,4 +4123,218 @@ mod tests { .is_some()); assert!(app.tabs[0].scene.document.objects.len() > objects); } + + #[test] + fn doc_api_layer_crud_roundtrip_and_entity_assignment() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Create a line to assign to a layer later. + let line = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, + start: [0.0; 3], + end: [1.0; 3], + })), + ) + .unwrap(), + ); + + // List default layers — must include "0". + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); + let QueryResult::Layers(list) = &receipt.query_results[0] else { + panic!("expected Layers result"); + }; + assert!(list.iter().any(|l| l.name == "0")); + + // Create a new layer. + let mut info = LayerInfo::new("Walls"); + info.color = Color::Index(1); + info.line_weight = LineWeight::Value(25); + info.flags = LayerFlags { + frozen: false, + locked: false, + frozen_in_new_viewport: false, + off: false, + }; + dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info.clone()))).unwrap(); + + // Update the layer color. + let mut updated = info.clone(); + updated.color = Color::Index(2); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::UpdateLayer { + name: "Walls".into(), + info: updated.clone(), + }), + ) + .unwrap(); + + // Assign the line to the new layer. + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetEntityLayer { + id: line, + layer: "Walls".into(), + }), + ) + .unwrap(); + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetEntityLayer { id: line }]), + ) + .unwrap(); + assert!(matches!(&receipt.query_results[0], QueryResult::EntityLayer(name) if name == "Walls")); + + // Deleting a layer still in use fails. + let err = dispatch( + &mut host, + DocApiEnvelope::op(Operation::DeleteLayer { + name: "Walls".into(), + }), + ) + .unwrap_err(); + assert!(matches!(err, ApiError::Validation { .. })); + + // Move the line back to "0", then delete the layer. + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetEntityLayer { + id: line, + layer: "0".into(), + }), + ) + .unwrap(); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::DeleteLayer { + name: "Walls".into(), + }), + ) + .unwrap(); + let receipt = dispatch(&mut host, DocApiEnvelope::queries(vec![Query::ListLayers])).unwrap(); + let QueryResult::Layers(list) = &receipt.query_results[0] else { + panic!("expected Layers result"); + }; + assert!(!list.iter().any(|l| l.name == "Walls")); + } + + #[test] + fn doc_api_enumerate_entities_and_geometry_queries() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + + // Create entities on different layers. + let line_id = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { layer: None, + start: [1.0, 2.0, 3.0], + end: [4.0, 5.0, 6.0], + })), + ) + .unwrap(), + ); + let point_id = new_id( + &dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Point { layer: None, + position: [7.0, 8.0, 9.0], + })), + ) + .unwrap(), + ); + + // Create a layer and move the point to it. + let mut info = LayerInfo::new("Markers"); + info.color = Color::Index(3); + dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(info))).unwrap(); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetEntityLayer { + id: point_id, + layer: "Markers".into(), + }), + ) + .unwrap(); + + // Enumerate all entities. + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::EnumerateEntities { + kind: None, + layer: None, + include_bounds: true, + }]), + ) + .unwrap(); + let QueryResult::Entities(all) = &receipt.query_results[0] else { + panic!("expected Entities result"); + }; + assert!(all.iter().any(|e| e.id == line_id && e.kind == "Line" && e.layer == "0")); + assert!(all.iter().any(|e| e.id == point_id && e.kind == "Point" && e.layer == "Markers")); + + // Filter by kind. + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::EnumerateEntities { + kind: Some("Line".into()), + layer: None, + include_bounds: true, + }]), + ) + .unwrap(); + let QueryResult::Entities(lines) = &receipt.query_results[0] else { + panic!("expected Entities result"); + }; + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].id, line_id); + + // Filter by layer. + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::EnumerateEntities { + kind: None, + layer: Some("Markers".into()), + include_bounds: true, + }]), + ) + .unwrap(); + let QueryResult::Entities(markers) = &receipt.query_results[0] else { + panic!("expected Entities result"); + }; + assert_eq!(markers.len(), 1); + assert_eq!(markers[0].id, point_id); + + // Geometry queries. + let receipt = dispatch( + &mut host, + DocApiEnvelope::queries(vec![ + Query::GetPointPosition { id: point_id }, + Query::GetLineGeometry { id: line_id }, + ]), + ) + .unwrap(); + let QueryResult::PointPosition(pos) = &receipt.query_results[0] else { + panic!("expected PointPosition result"); + }; + assert!((pos[0] - 7.0).abs() < 1e-9); + assert!((pos[1] - 8.0).abs() < 1e-9); + assert!((pos[2] - 9.0).abs() < 1e-9); + let QueryResult::LineGeometry((start, end)) = &receipt.query_results[1] else { + panic!("expected LineGeometry result"); + }; + assert!((start[0] - 1.0).abs() < 1e-9); + assert!((end[0] - 4.0).abs() < 1e-9); + + // Wrong entity family is rejected. + let err = dispatch( + &mut host, + DocApiEnvelope::queries(vec![Query::GetPointPosition { id: line_id }]), + ) + .unwrap_err(); + assert!(matches!(err, ApiError::Unsupported { .. })); + } } From c0b254950f93b35baff05a6327b4ea7085bff673 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:44:10 +0300 Subject: [PATCH 18/53] Restore editable extrusion surface grips --- src/app/command_driver.rs | 34 ++++++++++++++++++++++++++++---- src/app/model_ops.rs | 18 +++++++++++++++++ src/scene/model/solid_history.rs | 5 ++++- 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 11b618695..1f1156798 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -3756,10 +3756,36 @@ impl OpenCADStudio { let direction = direction .or(path_direction) .unwrap_or(glam::DVec3::ZERO); - self.add_surface_model( - empty_extruded_surface(direction, taper_angle), - body, - ) + let history = path + .is_none() + .then(|| { + sweep_model::extrusion_history( + &entity, + None, + direction.to_array(), + taper_angle, + profile.plane.origin, + ) + }) + .flatten(); + let mut surface = empty_extruded_surface(direction, taper_angle); + if let ( + Some(acadrust::objects::SolidHistoryOperation::Extrusion(value)), + acadrust::EntityType::Surface(entity), + ) = (&history, &mut surface) + { + if let Some(data) = + crate::scene::model::solid_history::extrusion_surface_data(value) + { + entity.surface_data = data; + } + } + match history { + Some(history) => { + self.add_surface_model_with_history(surface, body, history) + } + None => self.add_surface_model(surface, body), + } } else { let direction = direction.unwrap_or(glam::DVec3::ZERO); let history = path diff --git a/src/app/model_ops.rs b/src/app/model_ops.rs index 4bb5d2041..3d8b57541 100644 --- a/src/app/model_ops.rs +++ b/src/app/model_ops.rs @@ -402,6 +402,24 @@ impl super::OpenCADStudio { self.add_surface_model_inner(entity, surface, true) } + pub(super) fn add_surface_model_with_history( + &mut self, + entity: EntityType, + surface: Body, + history: SolidHistoryOperation, + ) -> Handle { + let i = self.active_tab; + let handle = self.add_surface_model_inner(entity, surface, true); + if handle.is_null() { + return handle; + } + if !self.tabs[i].scene.create_solid_history(handle, history) { + self.tabs[i].scene.rollback_new_entities(&[handle]); + return Handle::NULL; + } + handle + } + fn add_surface_model_preserving_style( &mut self, entity: EntityType, diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index f9cc88bb6..a11274ea4 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -3307,6 +3307,7 @@ pub fn primitive_grips( if let SolidHistoryOperation::Chamfer(value) = operation { return chamfer_distance_grips(document, handle, value); } + let is_surface = matches!(document.get_entity(handle), Some(EntityType::Surface(_))); let mut grips = Vec::new(); let mut add = |id, transform, point, shape, axis: Option<[f64; 3]>| { if let Some(world) = world_point(transform, point) { @@ -3528,7 +3529,9 @@ pub fn primitive_grips( } SolidHistoryOperation::Extrusion(value) => { let (profile_grips, profile_center) = extrusion_profile_grips(value); - grips.extend(profile_grips); + if !is_surface { + grips.extend(profile_grips); + } let direction = [value.direction.x, value.direction.y, value.direction.z]; if value.path_entity.is_none() { if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) { From 6ca16738f08dd89de37b861bad0dc2d3d0a10728 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Wed, 9 Sep 2026 15:07:39 +0100 Subject: [PATCH 19/53] fix(properties): sync polyline segment widths with global width and display varies when differing --- src/entities/common.rs | 2 + src/entities/lwpolyline.rs | 176 ++++++++++++++++++++++++++--- src/entities/polyline.rs | 190 ++++++++++++++++++++++++++++++-- src/scene/convert/tessellate.rs | 30 ++--- 4 files changed, 349 insertions(+), 49 deletions(-) diff --git a/src/entities/common.rs b/src/entities/common.rs index 1d1fbee41..57af9ab2f 100644 --- a/src/entities/common.rs +++ b/src/entities/common.rs @@ -4,6 +4,8 @@ use std::collections::HashSet; use crate::scene::model::object::{GripDef, GripShape, PropValue, Property}; use crate::scene::model::wire_model::PatternStationPiece; +pub const VARIES_LABEL: &str = "*VARIES*"; + /// Linear / angular unit format pulled from the document header so the /// per-thread properties pipeline can format values consistently without /// passing the document through every callsite. diff --git a/src/entities/lwpolyline.rs b/src/entities/lwpolyline.rs index 8b125fe39..67fa6e418 100644 --- a/src/entities/lwpolyline.rs +++ b/src/entities/lwpolyline.rs @@ -6,7 +6,7 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ edit_prop as edit, edit_scalar_prop as edit_scalar, format_area, format_length, parse_f64, - rectangle_grip, ro_prop as ro, square_grip, stepper_prop as stepper, + rectangle_grip, ro_prop as ro, square_grip, stepper_prop as stepper, VARIES_LABEL, }; use crate::entities::traits::RenderConvertible; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -18,14 +18,38 @@ const REVCLOUD_BULGE: f64 = 0.5; const MAX_REVCLOUD_VERTICES: usize = 100_000; const WIDTH_EPSILON: f64 = 1.0e-9; -fn effective_width(width: f64, constant_width: f64) -> f64 { - if width > WIDTH_EPSILON { - width +pub(crate) fn vertex_segment_widths(vertex: &LwVertex, constant_width: f64) -> (f64, f64) { + if vertex.start_width > WIDTH_EPSILON || vertex.end_width > WIDTH_EPSILON { + (vertex.start_width, vertex.end_width) } else { - constant_width + (constant_width, constant_width) } } +pub(crate) fn lwpolyline_global_width(pline: &LwPolyline) -> Option { + let count = pline.vertices.len(); + let seg_count = if pline.is_closed { + count + } else { + count.saturating_sub(1) + }; + if seg_count == 0 { + return Some(pline.constant_width); + } + let (w0_start, w0_end) = vertex_segment_widths(&pline.vertices[0], pline.constant_width); + if (w0_start - w0_end).abs() > 1e-6 { + return None; + } + let w0 = w0_start; + for i in 1..seg_count { + let (sw, ew) = vertex_segment_widths(&pline.vertices[i], pline.constant_width); + if (sw - ew).abs() > 1e-6 || (sw - w0).abs() > 1e-6 { + return None; + } + } + Some(w0) +} + /// Midpoint position on an arc segment defined by its bulge. fn arc_midpoint(p0: [f64; 2], p1: [f64; 2], bulge: f64) -> [f64; 2] { match crate::entities::common::BulgeArc::from_bulge(p0, p1, bulge) { @@ -504,8 +528,7 @@ fn band_verts(pline: &LwPolyline) -> Vec<([f64; 2], f64, f64, f64)> { .vertices .iter() .map(|v| { - let sw = effective_width(v.start_width, c); - let ew = effective_width(v.end_width, c); + let (sw, ew) = vertex_segment_widths(v, c); ([v.location.x, v.location.y], v.bulge, sw, ew) }) .collect() @@ -899,11 +922,8 @@ fn properties(pline: &LwPolyline) -> Vec { let v = pline.vertices.get(vi); let vx = v.map_or(0.0, |v| v.location.x); let vy = v.map_or(0.0, |v| v.location.y); - let start_w = v.map_or(0.0, |v| { - effective_width(v.start_width, pline.constant_width) - }); - let end_w = v.map_or(0.0, |v| { - effective_width(v.end_width, pline.constant_width) + let (start_w, end_w) = v.map_or((0.0, 0.0), |v| { + vertex_segment_widths(v, pline.constant_width) }); let mp = ::mass_props(pline); let cloud_arc_length = revision_cloud_arc_length(pline); @@ -937,6 +957,14 @@ fn properties(pline: &LwPolyline) -> Vec { arc_length, )); } + let global_width_prop = match lwpolyline_global_width(pline) { + Some(gw) => edit(t!("Global width").as_ref(), "global_width", gw), + None => Property { + label: t!("Global width").into_owned(), + field: "global_width", + value: PropValue::EditText(VARIES_LABEL.to_string()), + }, + }; let mut geometry_props = vec![ stepper(t!("Current Vertex").as_ref(), "current_vertex", vertex_label), edit(t!("Vertex X").as_ref(), "vertex_x", vx), @@ -948,7 +976,7 @@ fn properties(pline: &LwPolyline) -> Vec { geometry_props.push(edit(t!("End segment width").as_ref(), "end_width", end_w)); } geometry_props.extend([ - edit(t!("Global width").as_ref(), "global_width", pline.constant_width), + global_width_prop, edit(t!("Elevation").as_ref(), "elevation", pline.elevation), ro(t!("Area").as_ref(), "area", format_area(mp.area)), ro(t!("Length").as_ref(), "length", format_length(mp.perimeter)), @@ -1015,7 +1043,13 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { }; match field { "elevation" => pline.elevation = v, - "global_width" if v.is_finite() && v >= 0.0 => pline.constant_width = v, + "global_width" if v.is_finite() && v >= 0.0 => { + pline.constant_width = v; + for vtx in &mut pline.vertices { + vtx.start_width = v; + vtx.end_width = v; + } + } "vertex_x" => { if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.location.x = v; @@ -1027,11 +1061,31 @@ fn apply_geom_prop(pline: &mut LwPolyline, field: &str, value: &str) { } } "start_width" if v.is_finite() && v >= 0.0 => { + if pline.constant_width > WIDTH_EPSILON { + let c = pline.constant_width; + for vtx in &mut pline.vertices { + if vtx.start_width <= WIDTH_EPSILON && vtx.end_width <= WIDTH_EPSILON { + vtx.start_width = c; + vtx.end_width = c; + } + } + pline.constant_width = 0.0; + } if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.start_width = v; } } "end_width" if v.is_finite() && v >= 0.0 => { + if pline.constant_width > WIDTH_EPSILON { + let c = pline.constant_width; + for vtx in &mut pline.vertices { + if vtx.start_width <= WIDTH_EPSILON && vtx.end_width <= WIDTH_EPSILON { + vtx.start_width = c; + vtx.end_width = c; + } + } + pline.constant_width = 0.0; + } if let Some(vtx) = pline.vertices.get_mut(vi) { vtx.end_width = v; } @@ -1252,8 +1306,8 @@ impl crate::entities::traits::Grippable for LwPolyline { new_v.location.x = midpoint[0]; new_v.location.y = midpoint[1]; new_v.vertex_id = 0; - let effective_start = effective_width(v0.start_width, self.constant_width); - let effective_end = effective_width(v0.end_width, self.constant_width); + let (effective_start, effective_end) = + vertex_segment_widths(&v0, self.constant_width); let middle_width = (effective_start + effective_end) * 0.5; self.vertices[i0].end_width = middle_width; new_v.start_width = middle_width; @@ -1325,8 +1379,9 @@ pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> ([f64; 2], Vec< for i in 0..seg_count { let v0 = &verts[i]; let v1 = &verts[(i + 1) % n]; - let hw0 = effective_width(v0.start_width, pl.constant_width) as f32 * 0.5; - let hw1 = effective_width(v0.end_width, pl.constant_width) as f32 * 0.5; + let (sw, ew) = vertex_segment_widths(v0, pl.constant_width); + let hw0 = sw as f32 * 0.5; + let hw1 = ew as f32 * 0.5; if hw0 < 1e-6 && hw1 < 1e-6 { continue; } @@ -1381,3 +1436,88 @@ impl crate::entities::traits::MassPropsCalc for acadrust::entities::LwPolyline { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::traits::PropertyEditable; + use acadrust::entities::{LwPolyline, LwVertex}; + use acadrust::Vector2; + + fn make_test_lwpolyline(count: usize, constant_width: f64) -> LwPolyline { + let mut pl = LwPolyline::default(); + pl.constant_width = constant_width; + for i in 0..count { + pl.vertices.push(LwVertex::new(Vector2::new(i as f64 * 10.0, 0.0))); + } + pl + } + + #[test] + fn test_lwpolyline_uniform_width_shows_global_width() { + let pl = make_test_lwpolyline(3, 5.0); + assert_eq!(lwpolyline_global_width(&pl), Some(5.0)); + + let props = pl.geometry_properties(&[]); + let geom_props = &props[0].props; + let gw = geom_props.iter().find(|p| p.field == "global_width").unwrap(); + match &gw.value { + PropValue::EditText(val) => assert_eq!(parse_f64(val), Some(5.0)), + _ => panic!("expected EditText"), + } + } + + #[test] + fn test_lwpolyline_varying_vertex_width_shows_varies() { + let mut pl = make_test_lwpolyline(3, 5.0); + pl.vertices[0].start_width = 2.0; + pl.vertices[0].end_width = 3.0; + + assert_eq!(lwpolyline_global_width(&pl), None); + + let props = pl.geometry_properties(&[]); + let geom_props = &props[0].props; + let gw = geom_props.iter().find(|p| p.field == "global_width").unwrap(); + match &gw.value { + PropValue::EditText(val) => assert_eq!(val, VARIES_LABEL), + _ => panic!("expected EditText with VARIES_LABEL"), + } + } + + #[test] + fn test_lwpolyline_modifying_global_width_sets_all_vertex_widths() { + let mut pl = make_test_lwpolyline(3, 5.0); + pl.vertices[0].start_width = 2.0; + pl.vertices[0].end_width = 3.0; + + pl.apply_geom_prop("global_width", "10.0"); + + assert_eq!(pl.constant_width, 10.0); + for v in &pl.vertices { + assert_eq!(v.start_width, 10.0); + assert_eq!(v.end_width, 10.0); + } + assert_eq!(lwpolyline_global_width(&pl), Some(10.0)); + } + + #[test] + fn test_lwpolyline_modifying_segment_width_materializes_constant_width() { + let mut pl = make_test_lwpolyline(3, 5.0); + // Initially constant_width is 5.0, vertices start/end are 0.0 + assert_eq!(pl.vertices[0].start_width, 0.0); + + // Edit start width of current vertex (vertex 0) to 2.0 + pl.apply_geom_prop("start_width", "2.0"); + + // Vertex 0 start width should be 2.0, end width materialized to 5.0 + assert_eq!(pl.vertices[0].start_width, 2.0); + assert_eq!(pl.vertices[0].end_width, 5.0); + + // Vertex 1 and 2 should have materialized 5.0 instead of collapsing to 0.0 + assert_eq!(pl.vertices[1].start_width, 5.0); + assert_eq!(pl.vertices[1].end_width, 5.0); + + // Global width must now be VARIES + assert_eq!(lwpolyline_global_width(&pl), None); + } +} diff --git a/src/entities/polyline.rs b/src/entities/polyline.rs index de67a35dd..5b23a3ead 100644 --- a/src/entities/polyline.rs +++ b/src/entities/polyline.rs @@ -4,7 +4,7 @@ use crate::t; use crate::command::EntityTransform; use crate::entities::common::{ edit_prop as edit, format_length, parse_f64, ro_prop as ro, square_grip, - stepper_prop as stepper, + stepper_prop as stepper, VARIES_LABEL, }; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, RenderConvertible}; use crate::scene::convert::acad_to_render::{extrusion_wall_tris, RenderEntity, RenderObject}; @@ -609,6 +609,51 @@ impl Grippable for Polyline2D { } } +pub(crate) fn polyline2d_vertex_segment_widths( + vertex: &acadrust::entities::Vertex2D, + default_start: f64, + default_end: f64, +) -> (f64, f64) { + if vertex.start_width > 1e-9 || vertex.end_width > 1e-9 { + (vertex.start_width, vertex.end_width) + } else { + (default_start, default_end) + } +} + +pub(crate) fn polyline2d_global_width(pline: &Polyline2D) -> Option { + let filtered = drawn_vertices2d(pline); + let verts: &[acadrust::entities::Vertex2D] = + filtered.as_deref().unwrap_or(&pline.vertices); + let count = verts.len(); + let seg_count = if pline.is_closed() { + count + } else { + count.saturating_sub(1) + }; + if seg_count == 0 { + if (pline.start_width - pline.end_width).abs() < 1e-6 { + return Some(pline.start_width); + } else { + return None; + } + } + let (w0_start, w0_end) = + polyline2d_vertex_segment_widths(&verts[0], pline.start_width, pline.end_width); + if (w0_start - w0_end).abs() > 1e-6 { + return None; + } + let w0 = w0_start; + for i in 1..seg_count { + let (sw, ew) = + polyline2d_vertex_segment_widths(&verts[i], pline.start_width, pline.end_width); + if (sw - ew).abs() > 1e-6 || (sw - w0).abs() > 1e-6 { + return None; + } + } + Some(w0) +} + impl PropertyEditable for Polyline2D { fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { let n = self.vertices.len(); @@ -631,14 +676,24 @@ impl PropertyEditable for Polyline2D { let v = self.vertices.get(vi); let vertex_x = v.map(|v| v.location.x).unwrap_or_default(); let vertex_y = v.map(|v| v.location.y).unwrap_or_default(); - let seg_start_w = v.map(|v| v.start_width).unwrap_or_default(); - let seg_end_w = v.map(|v| v.end_width).unwrap_or_default(); + let (seg_start_w, seg_end_w) = v.map_or((0.0, 0.0), |v| { + polyline2d_vertex_segment_widths(v, self.start_width, self.end_width) + }); let vertex_label = if n == 0 { "—".to_string() } else { format!("{} / {}", vi + 1, n) }; + let global_width_prop = match polyline2d_global_width(self) { + Some(gw) => edit(t!("Global width").as_ref(), "pl2_start_w", gw), + None => Property { + label: t!("Global width").into_owned(), + field: "pl2_start_w", + value: PropValue::EditText(VARIES_LABEL.to_string()), + }, + }; + vec![ PropSection { title: t!("Geometry").into_owned(), @@ -648,7 +703,7 @@ impl PropertyEditable for Polyline2D { edit(t!("Vertex Y").as_ref(), "pl2_vertex_y", vertex_y), edit(t!("Start segment width").as_ref(), "pl2_seg_start_w", seg_start_w), edit(t!("End segment width").as_ref(), "pl2_seg_end_w", seg_end_w), - edit(t!("Global width").as_ref(), "pl2_start_w", self.start_width), + global_width_prop, edit(t!("Elevation").as_ref(), "pl2_elevation", self.elevation), ro(t!("Area").as_ref(), "pl2_area", format!("{area:.4}")), ro(t!("Length").as_ref(), "pl2_length", format!("{length:.4}")), @@ -706,9 +761,13 @@ impl PropertyEditable for Polyline2D { } "pl2_start_w" => { if let Some(v) = parse_f64(value) { - if v >= 0.0 { + if v.is_finite() && v >= 0.0 { self.start_width = v; self.end_width = v; + for vtx in &mut self.vertices { + vtx.start_width = v; + vtx.end_width = v; + } } } } @@ -723,16 +782,44 @@ impl PropertyEditable for Polyline2D { } } "pl2_seg_start_w" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.get_mut(vi)) { - if v >= 0.0 { - vert.start_width = v; + if let Some(v) = parse_f64(value) { + if v.is_finite() && v >= 0.0 { + if self.start_width > 1e-9 || self.end_width > 1e-9 { + let def_s = self.start_width; + let def_e = self.end_width; + for vtx in &mut self.vertices { + if vtx.start_width <= 1e-9 && vtx.end_width <= 1e-9 { + vtx.start_width = def_s; + vtx.end_width = def_e; + } + } + self.start_width = 0.0; + self.end_width = 0.0; + } + if let Some(vert) = self.vertices.get_mut(vi) { + vert.start_width = v; + } } } } "pl2_seg_end_w" => { - if let (Some(v), Some(vert)) = (parse_f64(value), self.vertices.get_mut(vi)) { - if v >= 0.0 { - vert.end_width = v; + if let Some(v) = parse_f64(value) { + if v.is_finite() && v >= 0.0 { + if self.start_width > 1e-9 || self.end_width > 1e-9 { + let def_s = self.start_width; + let def_e = self.end_width; + for vtx in &mut self.vertices { + if vtx.start_width <= 1e-9 && vtx.end_width <= 1e-9 { + vtx.start_width = def_s; + vtx.end_width = def_e; + } + } + self.start_width = 0.0; + self.end_width = 0.0; + } + if let Some(vert) = self.vertices.get_mut(vi) { + vert.end_width = v; + } } } } @@ -1331,3 +1418,84 @@ pub(crate) fn wide_fills(pl: &acadrust::entities::Polyline2D) -> ([f64; 2], Vec< } (origin, out) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::traits::PropertyEditable; + use acadrust::entities::{Polyline2D, Vertex2D}; + use acadrust::Vector3; + + fn make_test_polyline2d(count: usize, start_w: f64, end_w: f64) -> Polyline2D { + let mut pl = Polyline2D::default(); + pl.start_width = start_w; + pl.end_width = end_w; + for i in 0..count { + pl.vertices.push(Vertex2D::new(Vector3::new(i as f64 * 10.0, 0.0, 0.0))); + } + pl + } + + #[test] + fn test_polyline2d_uniform_width_shows_global_width() { + let pl = make_test_polyline2d(3, 4.0, 4.0); + assert_eq!(polyline2d_global_width(&pl), Some(4.0)); + + let props = pl.geometry_properties(&[]); + let geom_props = &props[0].props; + let gw = geom_props.iter().find(|p| p.field == "pl2_start_w").unwrap(); + match &gw.value { + PropValue::EditText(val) => assert_eq!(parse_f64(val), Some(4.0)), + _ => panic!("expected EditText"), + } + } + + #[test] + fn test_polyline2d_varying_vertex_width_shows_varies() { + let mut pl = make_test_polyline2d(3, 4.0, 4.0); + pl.vertices[0].start_width = 1.0; + pl.vertices[0].end_width = 2.0; + + assert_eq!(polyline2d_global_width(&pl), None); + + let props = pl.geometry_properties(&[]); + let geom_props = &props[0].props; + let gw = geom_props.iter().find(|p| p.field == "pl2_start_w").unwrap(); + match &gw.value { + PropValue::EditText(val) => assert_eq!(val, VARIES_LABEL), + _ => panic!("expected EditText with VARIES_LABEL"), + } + } + + #[test] + fn test_polyline2d_modifying_global_width_sets_all_vertex_widths() { + let mut pl = make_test_polyline2d(3, 4.0, 4.0); + pl.vertices[0].start_width = 1.0; + pl.vertices[0].end_width = 2.0; + + pl.apply_geom_prop("pl2_start_w", "8.0"); + + assert_eq!(pl.start_width, 8.0); + assert_eq!(pl.end_width, 8.0); + for v in &pl.vertices { + assert_eq!(v.start_width, 8.0); + assert_eq!(v.end_width, 8.0); + } + assert_eq!(polyline2d_global_width(&pl), Some(8.0)); + } + + #[test] + fn test_polyline2d_modifying_segment_width_materializes_default_width() { + let mut pl = make_test_polyline2d(3, 4.0, 4.0); + assert_eq!(pl.vertices[0].start_width, 0.0); + + pl.apply_geom_prop("pl2_seg_start_w", "1.5"); + + assert_eq!(pl.vertices[0].start_width, 1.5); + assert_eq!(pl.vertices[0].end_width, 4.0); + assert_eq!(pl.vertices[1].start_width, 4.0); + assert_eq!(pl.vertices[1].end_width, 4.0); + + assert_eq!(polyline2d_global_width(&pl), None); + } +} diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 8c9a22af5..143dde897 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -215,17 +215,12 @@ fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> { (0..seg_count) .map(|i| { let v = &p.vertices[i]; - let sw = if v.start_width > 1e-9 { - v.start_width + let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 { + (v.start_width, v.end_width) } else { - c - } as f32; - let ew = if v.end_width > 1e-9 { - v.end_width - } else { - c - } as f32; - (sw, ew) + (c, c) + }; + (sw as f32, ew as f32) }) .collect() } @@ -244,17 +239,12 @@ fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> { (0..seg_count) .map(|i| { let v = &verts[i]; - let sw = if v.start_width > 1e-9 { - v.start_width + let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 { + (v.start_width, v.end_width) } else { - def_start - } as f32; - let ew = if v.end_width > 1e-9 { - v.end_width - } else { - def_end - } as f32; - (sw, ew) + (def_start, def_end) + }; + (sw as f32, ew as f32) }) .collect() } From 0ad31ece6fe82a46e3540a006ca02dd2d3afb670 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Thu, 10 Sep 2026 11:59:27 +0100 Subject: [PATCH 20/53] fix(render): correct polyline segment widths, flat arc end caps, and zero-width endpoints --- src/scene/convert/tessellate.rs | 300 +++++++++++++++++++++++++--- src/scene/pipeline/circle_gpu.rs | 2 +- src/scene/pipeline/wire_gpu.rs | 26 +-- src/shaders/block_wire.wgsl | 9 +- src/shaders/block_wire_storage.wgsl | 9 +- src/shaders/circle.wgsl | 56 ++++-- src/shaders/wire.wgsl | 8 +- src/shaders/wire_indexed.wgsl | 9 +- 8 files changed, 346 insertions(+), 73 deletions(-) diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 143dde897..829b20cf1 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -282,7 +282,53 @@ fn split_mixed_polyline( end_angle, } = *tg { + let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { + (w0, w1) + } else if global_world_width > 1e-9 { + (global_world_width, global_world_width) + } else { + (0.0, 0.0) + }; + + // Check if the CCW start_angle of this TangentGeom::Arc corresponds + // to the segment's end vertex (clockwise bulge arc in CAD where + // bulge_arc_to_tangent inverted start_angle and end_angle to enforce CCW). + let (p_seg_start, p_seg_end) = if i + 1 < key_vertices.len() { + (key_vertices[i], key_vertices[i + 1]) + } else if !key_vertices.is_empty() { + ( + key_vertices[i % key_vertices.len()], + key_vertices[(i + 1) % key_vertices.len()], + ) + } else { + ([0.0; 3], [0.0; 3]) + }; + + let p_sa = [ + center[0] + radius * (start_angle.cos() * axis_x[0] + start_angle.sin() * axis_y[0]), + center[1] + radius * (start_angle.cos() * axis_x[1] + start_angle.sin() * axis_y[1]), + center[2] + radius * (start_angle.cos() * axis_x[2] + start_angle.sin() * axis_y[2]), + ]; + let is_reversed = if !key_vertices.is_empty() { + let d_start_sq = (p_sa[0] - p_seg_start[0]).powi(2) + + (p_sa[1] - p_seg_start[1]).powi(2) + + (p_sa[2] - p_seg_start[2]).powi(2); + let d_end_sq = (p_sa[0] - p_seg_end[0]).powi(2) + + (p_sa[1] - p_seg_end[1]).powi(2) + + (p_sa[2] - p_seg_end[2]).powi(2); + d_end_sq < d_start_sq + } else { + false + }; + + let (w_at_sa, w_at_ea) = if is_reversed { + (ew, sw) + } else { + (sw, ew) + }; + let mut arc_pts = Vec::with_capacity(17); + let mut arc_widths = Vec::with_capacity(17); let n = 16; let sweep = if end_angle >= start_angle { end_angle - start_angle @@ -298,25 +344,12 @@ fn split_mixed_polyline( center[2] + radius * (ang.cos() * axis_x[2] + ang.sin() * axis_y[2]), ]; arc_pts.push(p); + arc_widths.push(w_at_sa + (w_at_ea - w_at_sa) * frac as f32); } let (points, points_low) = points_to_ds(arc_pts); - let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { - if w0 > 1e-9 || w1 > 1e-9 { - (w0, w1) - } else if global_world_width > 1e-9 { - (global_world_width, global_world_width) - } else { - (0.0, 0.0) - } - } else if global_world_width > 1e-9 { - (global_world_width, global_world_width) - } else { - (0.0, 0.0) - }; - - let taper_widths = if (sw - ew).abs() > 1e-6 { - vec![sw, ew] + let taper_widths = if (w_at_sa - w_at_ea).abs() > 1e-6 { + arc_widths } else { Vec::new() }; @@ -367,43 +400,77 @@ fn split_mixed_polyline( // Collect straight lines into a line wire let mut straight_pts: Vec<[f64; 3]> = Vec::new(); + let mut straight_widths: Vec = Vec::new(); let mut straight_tangents: Vec = Vec::new(); let mut last_end: Option<[f64; 3]> = None; + let mut last_end_w: f32 = 0.0; for (i, tg) in tangent_geoms.iter().enumerate() { if let TangentGeom::Line { p1, p2 } = tg { straight_tangents.push(tg.clone()); - let (p_start, p_end) = if i + 1 < key_vertices.len() { - (key_vertices[i], key_vertices[i + 1]) + let p_start = if i + 1 < key_vertices.len() + && (key_vertices[i][0] as f32 - p1[0]).abs() < 1e-3 + && (key_vertices[i][1] as f32 - p1[1]).abs() < 1e-3 + && (key_vertices[i][2] as f32 - p1[2]).abs() < 1e-3 + { + key_vertices[i] } else { - ( - [p1[0] as f64, p1[1] as f64, p1[2] as f64], - [p2[0] as f64, p2[1] as f64, p2[2] as f64], - ) + [p1[0] as f64, p1[1] as f64, p1[2] as f64] + }; + let p_end = if i + 1 < key_vertices.len() + && (key_vertices[i + 1][0] as f32 - p2[0]).abs() < 1e-3 + && (key_vertices[i + 1][1] as f32 - p2[1]).abs() < 1e-3 + && (key_vertices[i + 1][2] as f32 - p2[2]).abs() < 1e-3 + { + key_vertices[i + 1] + } else { + [p2[0] as f64, p2[1] as f64, p2[2] as f64] + }; + let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) { + (w0, w1) + } else if global_world_width > 1e-9 { + (global_world_width, global_world_width) + } else { + (0.0, 0.0) }; if !plinegen { if !straight_pts.is_empty() { straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); } straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } else { if let Some(prev) = last_end { if (prev[0] - p_start[0]).abs() < 1e-7 && (prev[1] - p_start[1]).abs() < 1e-7 && (prev[2] - p_start[2]).abs() < 1e-7 + && (last_end_w - sw).abs() < 1e-6 { straight_pts.push(p_end); + straight_widths.push(ew); } else { straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } } else { + if !straight_pts.is_empty() { + straight_pts.push([f64::NAN; 3]); + straight_widths.push(0.0); + } straight_pts.push(p_start); + straight_widths.push(sw); straight_pts.push(p_end); + straight_widths.push(ew); } last_end = Some(p_end); + last_end_w = ew; } } else { last_end = None; @@ -417,12 +484,24 @@ fn split_mixed_polyline( } else { (Vec::new(), Vec::new()) }; + let line_world_width = straight_widths + .iter() + .copied() + .fold(0.0f32, f32::max); + let has_line_taper = straight_widths + .first() + .map_or(false, |&w0| straight_widths.iter().any(|&w| (w - w0).abs() > 1e-6)); + let taper_widths = if has_line_taper || straight_widths.iter().any(|&w| w > 1e-6) { + straight_widths + } else { + Vec::new() + }; out.push(WireModel { bg_adapt: None, point_marker, - taper_widths: Vec::new(), + taper_widths, pattern_stations: Vec::new(), - world_width: global_world_width, + world_width: line_world_width, depth_override: None, display_visible: true, plot_visible: true, @@ -2979,3 +3058,176 @@ pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 { v.normalize() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_split_mixed_polyline_straight_lines_have_taper_widths() { + let tg_line = TangentGeom::Line { + p1: [0.0, 0.0, 0.0], + p2: [10.0, 0.0, 0.0], + }; + let tg_arc = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tgs = vec![tg_line, tg_arc]; + let kv = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [15.0, 5.0, 0.0]]; + let seg_widths = vec![(0.0f32, 10.0f32), (10.0f32, 20.0f32)]; + + let wires = split_mixed_polyline( + &tgs, + &kv, + "mixed", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 20.0, + Vec::new(), + Vec::new(), + ); + + // Arc wire + Line wire + assert_eq!(wires.len(), 2); + let arc_wire = &wires[0]; + assert_eq!(arc_wire.taper_widths.first().copied(), Some(10.0)); + assert_eq!(arc_wire.taper_widths.last().copied(), Some(20.0)); + + let line_wire = &wires[1]; + assert_eq!(line_wire.taper_widths, vec![0.0, 10.0]); + assert_eq!(line_wire.world_width, 10.0); + } + + #[test] + fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() { + let tg_line = TangentGeom::Line { + p1: [0.0, 0.0, 0.0], + p2: [10.0, 0.0, 0.0], + }; + let tg_arc1 = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tg_arc2 = TangentGeom::Arc { + center: [15.0, 10.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: -std::f64::consts::FRAC_PI_2, + end_angle: 0.0, + }; + let tgs = vec![tg_line, tg_arc1, tg_arc2]; + let kv = vec![ + [0.0, 0.0, 0.0], + [10.0, 0.0, 0.0], + [15.0, 5.0, 0.0], + [20.0, 10.0, 0.0], + ]; + // Segment 0 has width (2.0, 0.0), segment 1 and 2 have width (0.0, 0.0) + let seg_widths = vec![(2.0f32, 0.0f32), (0.0f32, 0.0f32), (0.0f32, 0.0f32)]; + + let wires = split_mixed_polyline( + &tgs, + &kv, + "taper_then_zero", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 2.0, + Vec::new(), + Vec::new(), + ); + + // 2 arc wires + 1 line wire = 3 wires + assert_eq!(wires.len(), 3); + let arc1 = &wires[0]; + let arc2 = &wires[1]; + let line = &wires[2]; + + // Arcs must have world_width = 0.0 and empty taper_widths + assert_eq!(arc1.world_width, 0.0); + assert!(arc1.taper_widths.is_empty()); + assert_eq!(arc2.world_width, 0.0); + assert!(arc2.taper_widths.is_empty()); + + let inst1 = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc1, 0.0) + .expect("arc1 instance"); + assert_eq!(inst1.start_width, 0.0); + assert_eq!(inst1.params[3], 0.0); + + // Straight line must have world_width = 2.0 and taper_widths = [2.0, 0.0] + assert_eq!(line.world_width, 2.0); + assert_eq!(line.taper_widths, vec![2.0, 0.0]); + } + + #[test] + fn test_split_mixed_polyline_reversed_arc_aligns_endpoints() { + // Clockwise arc from (10.0, 0.0) to (15.0, 5.0). + // bulge_arc_to_tangent inverts angles so start_angle is at (15.0, 5.0) and end_angle at (10.0, 0.0). + let tg_arc_reversed = TangentGeom::Arc { + center: [10.0, 5.0, 0.0], + axis_x: [1.0, 0.0, 0.0], + axis_y: [0.0, 1.0, 0.0], + radius: 5.0, + start_angle: 0.0, // at (15.0, 5.0) - physical end! + end_angle: -std::f64::consts::FRAC_PI_2, // at (10.0, 0.0) - physical start! + }; + let tgs = vec![tg_arc_reversed]; + let kv = vec![[10.0, 0.0, 0.0], [15.0, 5.0, 0.0]]; + let seg_widths = vec![(5.0f32, 25.0f32)]; // sw=5.0 at start, ew=25.0 at end + + let wires = split_mixed_polyline( + &tgs, + &kv, + "arc_rev", + [1.0, 1.0, 1.0, 1.0], + false, + 0.0, + [0.0; 8], + 1.0, + Vec::new(), + None, + true, + &seg_widths, + 25.0, + Vec::new(), + Vec::new(), + ); + + assert_eq!(wires.len(), 1); + let arc_wire = &wires[0]; + // In CCW shader angles, start_angle is at (15.0, 5.0) which is physical end (ew = 25.0). + // end_angle is at (10.0, 0.0) which is physical start (sw = 5.0). + assert_eq!(arc_wire.taper_widths.first().copied(), Some(25.0)); + assert_eq!(arc_wire.taper_widths.last().copied(), Some(5.0)); + + let inst = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc_wire, 0.0) + .expect("arc instance extracted"); + // start_width is at start_angle (physical end, 25.0) + assert_eq!(inst.start_width, 25.0); + // params.w (end_width) is at end_angle (physical start, 5.0) + assert_eq!(inst.params[3], 5.0); + } +} diff --git a/src/scene/pipeline/circle_gpu.rs b/src/scene/pipeline/circle_gpu.rs index f38b46605..f5e898cd5 100644 --- a/src/scene/pipeline/circle_gpu.rs +++ b/src/scene/pipeline/circle_gpu.rs @@ -258,7 +258,7 @@ pub fn extract_circle_instance_from_geom_indexed( let (start_width, end_width) = if !wire.taper_widths.is_empty() { if wire.taper_widths.len() >= 2 && wire.tangent_geoms.len() == 1 { - (wire.taper_widths[0], wire.taper_widths[1]) + (wire.taper_widths[0], *wire.taper_widths.last().unwrap()) } else if wire.taper_widths.len() > geom_index { let sw = wire.taper_widths[geom_index]; let ew = wire.taper_widths.get(geom_index + 1).copied().unwrap_or(sw); diff --git a/src/scene/pipeline/wire_gpu.rs b/src/scene/pipeline/wire_gpu.rs index e3901ee3d..d71c15d58 100644 --- a/src/scene/pipeline/wire_gpu.rs +++ b/src/scene/pipeline/wire_gpu.rs @@ -116,7 +116,7 @@ pub struct WireConst { /// the quad by `world_half_width / world_per_pixel` so the band tracks zoom /// in drawing units. pub world_half_width: f32, - pub _pad1: f32, + pub is_tapered: f32, pub _pad2: f32, /// Point-marker origin as a double-single pair. `marker_normal_scale.w` /// stores the viewport-height percentage; zero disables marker scaling. @@ -592,7 +592,14 @@ pub(crate) fn emit_wire_packed( let (dists, align_end, align_total) = wire_distances(wire); let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire); let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - let tw = |i: usize| -> f32 { wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 }; + let is_tapered = !wire.taper_widths.is_empty(); + let tw = |i: usize| -> f32 { + if is_tapered { + wire.taper_widths.get(i).copied().unwrap_or(0.0) * 0.5 + } else { + -1.0 + } + }; let mut instances: Vec = Vec::with_capacity(seg_count); for i in 0..seg_count { let a = wire.points[i]; @@ -636,6 +643,7 @@ pub(crate) fn emit_wire_native( ) -> (Vec, WireConst) { let (dists, align_end, align_total) = wire_distances(wire); let (marker_origin_high, marker_origin_low, marker_normal_scale) = marker_metadata(wire); + let is_tapered = !wire.taper_widths.is_empty(); let cst = WireConst { color, pat0: [wire.pattern[0], wire.pattern[1], wire.pattern[2], wire.pattern[3]], @@ -646,7 +654,7 @@ pub(crate) fn emit_wire_native( align_end, align_total, world_half_width: wire.world_width * 0.5, - _pad1: 0.0, + is_tapered: if is_tapered { 1.0 } else { 0.0 }, _pad2: 0.0, marker_origin_high, marker_origin_low, @@ -658,18 +666,12 @@ pub(crate) fn emit_wire_native( return (Vec::new(), cst); } let low = |i: usize| -> [f32; 3] { wire.points_low.get(i).copied().unwrap_or([0.0; 3]) }; - // Store an endpoint/max-width ratio. The shared f32 maximum keeps drawing - // units and range out of the packed field; UNORM16 contributes only a - // relative error below 1/65535. Preserve zero as the existing constant - // width fallback sentinel. let taper_ratio = |i: usize| -> u16 { - let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); - if width <= 0.0 || wire.world_width <= 0.0 { + if !is_tapered || wire.world_width <= 0.0 { 0 } else { - ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32) - .round() - .max(1.0) as u16 + let width = wire.taper_widths.get(i).copied().unwrap_or(0.0); + ((width / wire.world_width).clamp(0.0, 1.0) * u16::MAX as f32).round() as u16 } }; let mut instances: Vec = Vec::with_capacity(seg_count); diff --git a/src/shaders/block_wire.wgsl b/src/shaders/block_wire.wgsl index d70301806..ba3adb899 100644 --- a/src/shaders/block_wire.wgsl +++ b/src/shaders/block_wire.wgsl @@ -24,7 +24,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -121,7 +121,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let rel_a = marker_relative(in.pos_a, in.pos_a_low, in.translation, in.translation_low); let rel_b = marker_relative(in.pos_b, in.pos_b_low, in.translation, in.translation_low); - let is_world = wire_const.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0; + let is_tapered = wire_const.is_tapered > 0.5; + let is_world = wire_const.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -134,8 +135,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. - let world_hw_a = select(wire_const.world_half_width, in.taper_ratio.x * wire_const.world_half_width, in.taper_ratio.x > 0.0); - let world_hw_b = select(wire_const.world_half_width, in.taper_ratio.y * wire_const.world_half_width, in.taper_ratio.y > 0.0); + let world_hw_a = select(wire_const.world_half_width, in.taper_ratio.x * wire_const.world_half_width, is_tapered); + let world_hw_b = select(wire_const.world_half_width, in.taper_ratio.y * wire_const.world_half_width, is_tapered); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); diff --git a/src/shaders/block_wire_storage.wgsl b/src/shaders/block_wire_storage.wgsl index 3b3544e22..ea952bca2 100644 --- a/src/shaders/block_wire_storage.wgsl +++ b/src/shaders/block_wire_storage.wgsl @@ -43,7 +43,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -152,7 +152,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut let rel_a = marker_relative(seg_pos_a, seg_pos_a_low, in.translation, in.translation_low); let rel_b = marker_relative(seg_pos_b, seg_pos_b_low, in.translation, in.translation_low); - let is_world = wire_const.world_half_width > 0.0 || seg_taper_ratio.x > 0.0 || seg_taper_ratio.y > 0.0; + let is_tapered = wire_const.is_tapered > 0.5; + let is_world = wire_const.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -165,8 +166,8 @@ fn vs_main(@builtin(vertex_index) vertex_index: u32, in: VertexIn) -> VertexOut // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. - let world_hw_a = select(wire_const.world_half_width, seg_taper_ratio.x * wire_const.world_half_width, seg_taper_ratio.x > 0.0); - let world_hw_b = select(wire_const.world_half_width, seg_taper_ratio.y * wire_const.world_half_width, seg_taper_ratio.y > 0.0); + let world_hw_a = select(wire_const.world_half_width, seg_taper_ratio.x * wire_const.world_half_width, is_tapered); + let world_hw_b = select(wire_const.world_half_width, seg_taper_ratio.y * wire_const.world_half_width, is_tapered); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); diff --git a/src/shaders/circle.wgsl b/src/shaders/circle.wgsl index 22b0973f8..43d157286 100644 --- a/src/shaders/circle.wgsl +++ b/src/shaders/circle.wgsl @@ -207,32 +207,48 @@ fn in_dash(dist: f32, pat_len: f32, p0: vec4, p1: vec4) -> bool { let is_full_or_in_sweep = (sweep >= TAU - 1e-5) || (d_theta <= sweep); if in.is_world > 0.5 { - // Physical world-unit width (thick analytical circular arcs, donuts, tapered segments). - // Distance-to-edge calculations are strictly performed in the circle plane's world units - // so that 3D perspective projection yields exact concentric ellipses with invariant - // physical stroke widths across any camera tilt or acute perspective angle. - if is_full_or_in_sweep { - let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); - let world_hw = mix(in.hw_ends.x, in.hw_ends.y, t); - let eff_hw = max(world_hw, 0.5 * fw); + let is_full = sweep >= TAU - 1e-5; + if is_full { + let eff_hw = max(in.hw_ends.x, 0.5 * fw); let d_world = abs(r - in.radius); let delta_px = (eff_hw - d_world) / fw; let cov = clamp(0.5 + delta_px, 0.0, 1.0); - let subpixel_fade = min(1.0, (2.0 * world_hw) / fw); + let subpixel_fade = min(1.0, (2.0 * in.hw_ends.x) / fw); alpha_cov = cov * subpixel_fade; arc_dist = in.radius * d_theta; } else { - // Outside sweep: evaluate distance to start and end endpoints for smooth round end caps - let p_start = in.radius * vec2(cos(sa), sin(sa)); - let p_end = in.radius * vec2(cos(ea), sin(ea)); - let d_start = length(in.local_pos - p_start); - let d_end = length(in.local_pos - p_end); - let eff_hw_a = max(in.hw_ends.x, 0.5 * fw); - let eff_hw_b = max(in.hw_ends.y, 0.5 * fw); - let cov_start = clamp(0.5 + (eff_hw_a - d_start) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.x) / fw); - let cov_end = clamp(0.5 + (eff_hw_b - d_end) / fw, 0.0, 1.0) * min(1.0, (2.0 * in.hw_ends.y) / fw); - alpha_cov = max(cov_start, cov_end); - arc_dist = select(in.radius * sweep, 0.0, cov_start >= cov_end); + // Flat (radial) end caps perpendicular to arc tangent at start_angle and end_angle. + let d_start_rad = r * d_theta; + let d_end_rad = r * (sweep - d_theta); + let d_before_sa = r * mod_tau(sa - theta); + let d_after_ea = r * mod_tau(theta - ea); + + var cov_angular: f32 = 0.0; + var world_hw: f32 = 0.0; + + if is_full_or_in_sweep { + let t = select(0.0, clamp(d_theta / sweep, 0.0, 1.0), sweep > 1e-5); + world_hw = mix(in.hw_ends.x, in.hw_ends.y, t); + let d_inside = min(d_start_rad, d_end_rad); + cov_angular = clamp(0.5 + d_inside / fw, 0.0, 1.0); + arc_dist = in.radius * d_theta; + } else { + let d_outside = min(d_before_sa, d_after_ea); + if d_outside <= 0.5 * fw { + cov_angular = clamp(0.5 - d_outside / fw, 0.0, 1.0); + world_hw = select(in.hw_ends.y, in.hw_ends.x, d_before_sa <= d_after_ea); + arc_dist = select(in.radius * sweep, 0.0, d_before_sa <= d_after_ea); + } + } + + if cov_angular > 0.0 { + let eff_hw = max(world_hw, 0.5 * fw); + let d_world = abs(r - in.radius); + let delta_px = (eff_hw - d_world) / fw; + let cov_radial = clamp(0.5 + delta_px, 0.0, 1.0); + let subpixel_fade = min(1.0, (2.0 * world_hw) / fw); + alpha_cov = cov_radial * cov_angular * subpixel_fade; + } } } else { // Lineweight stroke (screen-pixel width) diff --git a/src/shaders/wire.wgsl b/src/shaders/wire.wgsl index afa7a0d75..f1622b691 100644 --- a/src/shaders/wire.wgsl +++ b/src/shaders/wire.wgsl @@ -102,7 +102,7 @@ struct VertexOut { // constant world-unit band, then the screen-pixel lineweight (LWDISPLAY off // collapses to a hairline). fn resolve_hw(taper: f32, world_hw: f32, px_hw: f32) -> f32 { - if taper > 0.0 { return max(taper / u.world_per_pixel, 0.5); } + if taper >= 0.0 { return max(taper / u.world_per_pixel, 0.5); } if world_hw > 0.0 { return max(world_hw / u.world_per_pixel, 0.5); } var display_hw = max(px_hw * u.lineweight_scale, 0.5); if u.lineweight_scale < 0.0 { @@ -156,7 +156,7 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: // UTM-scale coordinates and after a cross-drawing paste, with no jitter. let rel_a = marker_relative(in.pos_a, in.pos_a_low, in); let rel_b = marker_relative(in.pos_b, in.pos_b_low, in); - let is_world = in.misc.w > 0.0 || in.taper.x > 0.0 || in.taper.y > 0.0; + let is_world = in.misc.w > 0.0 || in.taper.x >= 0.0 || in.taper.y >= 0.0; var final_clip: vec4; var out_dist: f32; @@ -169,8 +169,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, instance: // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. - let world_hw_a = select(in.misc.w, in.taper.x, in.taper.x > 0.0); - let world_hw_b = select(in.misc.w, in.taper.y, in.taper.y > 0.0); + let world_hw_a = select(in.misc.w, in.taper.x, in.taper.x >= 0.0); + let world_hw_b = select(in.misc.w, in.taper.y, in.taper.y >= 0.0); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); diff --git a/src/shaders/wire_indexed.wgsl b/src/shaders/wire_indexed.wgsl index d739d6e15..a18ee51b6 100644 --- a/src/shaders/wire_indexed.wgsl +++ b/src/shaders/wire_indexed.wgsl @@ -32,7 +32,7 @@ struct WireConst { align_end: f32, align_total: f32, world_half_width: f32, - _pad1: f32, + is_tapered: f32, _pad2: f32, marker_origin_high: vec4, marker_origin_low: vec4, @@ -128,7 +128,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon let rel_a = marker_relative(in.pos_a, in.pos_a_low, c); let rel_b = marker_relative(in.pos_b, in.pos_b_low, c); - let is_world = c.world_half_width > 0.0 || in.taper_ratio.x > 0.0 || in.taper_ratio.y > 0.0; + let is_tapered = c.is_tapered > 0.5; + let is_world = c.world_half_width > 0.0; var final_clip: vec4; var out_dist: f32; @@ -141,8 +142,8 @@ fn marker_relative(position_high: vec3, position_low: vec3, c: WireCon // the segment and the plane normal), so the rectangle remains hosted rigidly // on the 3D plane when the camera rotates, orbits, or tilts, matching CAD // behavior and circle.wgsl planar arcs. - let world_hw_a = select(c.world_half_width, in.taper_ratio.x * c.world_half_width, in.taper_ratio.x > 0.0); - let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, in.taper_ratio.y > 0.0); + let world_hw_a = select(c.world_half_width, in.taper_ratio.x * c.world_half_width, is_tapered); + let world_hw_b = select(c.world_half_width, in.taper_ratio.y * c.world_half_width, is_tapered); let cur_world_hw = mix(world_hw_a, world_hw_b, which_end); let eff_hw = max(cur_world_hw, 0.5 * u.world_per_pixel); From a24d1e610887fe64a5be47c312891b10d43367c4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 14:53:48 +0300 Subject: [PATCH 21/53] Fix live extrusion surface grip editing --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/update/viewport.rs | 53 +++++++++++++++++++++++++------ src/modules/model/edge_cmd.rs | 2 +- src/scene/convert/acis_kernel.rs | 4 ++- src/scene/convert/solid3d_tess.rs | 8 +++++ src/scene/mod.rs | 5 +++ src/scene/model/solid_history.rs | 19 ++++++++--- src/scene/model/solid_model.rs | 41 +++++++++++++++++++----- src/scene/modify.rs | 10 +++++- 11 files changed, 121 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 10aaeed9d..d77046e7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=dfd0de176a878c20a1aeb8d23b0b8265197b5f5c#dfd0de176a878c20a1aeb8d23b0b8265197b5f5c" +source = "git+https://github.com/ramox81/cadkernel.git?rev=c584d19dc45b3bef5146d6cf2e3dae79b72e9468#c584d19dc45b3bef5146d6cf2e3dae79b72e9468" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 49870b58e..f7a8295a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "c584d19dc45b3bef5146d6cf2e3dae79b72e9468", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 592effe98..4c902ed91 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "dfd0de176a878c20a1aeb8d23b0b8265197b5f5c", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "c584d19dc45b3bef5146d6cf2e3dae79b72e9468", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index df4c7a11d..96eeff041 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1535,15 +1535,30 @@ impl OpenCADStudio { } if let Some(axis) = grip.axis { - snapped = cursor_on_projected_axis( - p, - bounds, - view_rot, - eye, - grip.origin_world, - axis, - ) - .unwrap_or(snapped); + let acquired_point = otrack_hit.is_some() + || snap_hit.is_some_and(|hit| { + hit.snap_type != crate::snap::SnapType::Grid + }); + if acquired_point { + if let Some(axis) = axis.try_normalize() { + // An axis grip still moves on its construction axis, but + // an acquired object point supplies the exact coordinate + // along that axis. This lets height and draft arrows seat + // back onto existing surface/solid geometry. + snapped = grip.origin_world + + axis * (snapped - grip.origin_world).dot(axis); + } + } else { + snapped = cursor_on_projected_axis( + p, + bounds, + view_rot, + eye, + grip.origin_world, + axis, + ) + .unwrap_or(snapped); + } } let snap_ms = snap_started.elapsed().as_secs_f64() * 1000.0; @@ -1751,6 +1766,26 @@ impl OpenCADStudio { let preview_ms = preview_started.elapsed().as_secs_f64() * 1000.0; let geometry_ms = grip_started.elapsed().as_secs_f64() * 1000.0; self.refresh_selected_grips(); + // A history rebuild can clamp a requested value or move a derived + // construction handle non-linearly. Rebase each active target onto + // the freshly rebuilt grip so the hot arrow remains attached to the + // live surface and reverses immediately from a geometric limit. + let refreshed_targets: Vec<_> = self.tabs[i] + .selected_grip_handles + .iter() + .copied() + .zip(self.tabs[i].selected_grips.iter()) + .map(|(handle, grip)| ((handle, grip.id), grip.world)) + .collect(); + if let Some(active) = self.tabs[i].active_grip.as_mut() { + for target in &mut active.targets { + if let Some((_, world)) = refreshed_targets.iter().find(|((handle, id), _)| { + *handle == target.handle && *id == target.grip_id + }) { + target.last_world = *world; + } + } + } let grips_ms = grip_started.elapsed().as_secs_f64() * 1000.0 - geometry_ms; // Properties are refreshed when the grip is committed or cancelled. // Rebuilding the inspector on every pointer event adds no drawing diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index ae7cb1303..59a8a9c4b 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -210,7 +210,7 @@ impl SolidEdgeCommand { ) .ok()?, }; - let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle); + let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle, 0); for wire in &mut wires { wire.color = self.preview_color; wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name()); diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index c21c69364..4edc63268 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -25,6 +25,7 @@ pub fn tessellate_sat( facet_res: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, ) -> Option { let (bodies, loss) = lift(document); if bodies.is_empty() { @@ -114,7 +115,8 @@ pub fn tessellate_sat( max_angle, source_fit * placement_scale, ) - .with_isolines(isolines); + .with_isolines(isolines) + .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); } diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 598627e8c..3015b4a7f 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -11,6 +11,7 @@ fn tessellate_acis( facet_res: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, ) -> Option { crate::scene::convert::acis_kernel::tessellate_sat( sat, @@ -19,6 +20,7 @@ fn tessellate_acis( facet_res, chordal_deflection, isolines, + planar_isolines, ) } @@ -219,6 +221,7 @@ fn finish( facet_res: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, acis: &acadrust::entities::AcisData, ) -> Option { let mut set = tessellate_acis( @@ -228,6 +231,7 @@ fn finish( facet_res, chordal_deflection, isolines, + planar_isolines, )?; remap_acis_material_bindings(&mut set, acis); Some(set) @@ -252,6 +256,7 @@ pub fn tessellate_region( facet_res, chordal_deflection, isolines, + false, ®ion.acis_data, ) } @@ -275,6 +280,7 @@ pub fn tessellate_body( facet_res, chordal_deflection, isolines, + false, &body.acis_data, ) } @@ -298,6 +304,7 @@ pub fn tessellate_surface( facet_res, chordal_deflection, isolines, + true, &surface.acis_data, ) } @@ -321,6 +328,7 @@ pub fn tessellate_solid3d( facet_res, chordal_deflection, isolines, + false, &solid.acis_data, ) } diff --git a/src/scene/mod.rs b/src/scene/mod.rs index f136d16e3..bd2cf4fb0 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3083,6 +3083,10 @@ impl Scene { let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_resolution); let isolines = self.document.header.isolines.max(0) as usize; + let planar_isolines = matches!( + self.document.get_entity(handle), + Some(EntityType::Surface(_)) + ); let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( solid, @@ -3090,6 +3094,7 @@ impl Scene { facet_resolution, chordal_deflection, isolines, + planar_isolines, )?; let name = handle.value().to_string(); for mesh in &mut set.lods { diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index a11274ea4..ba6027255 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -3537,21 +3537,32 @@ pub fn primitive_grips( if let (Some(center), Some(base)) = (profile_center, matrix(value.base.transform)) { let direction = base.transform_vector3(glam::DVec3::from_array(direction)); if direction.length_squared() > 1e-12 { - grips.push(grip( + let mut height_grip = grip( GRIP_HEIGHT, center + direction, GripShape::Triangle, Some(direction.normalize()), - )); + ); + if is_surface { + // Surface construction arrows keep one stable screen + // orientation while their world-space axis continues to + // constrain the edit. + height_grip.dir = None; + } + grips.push(height_grip); } } if let Some((world, _, radial, _, _)) = extrusion_draft_grip(value) { - grips.push(grip( + let mut draft_grip = grip( GRIP_DRAFT, world, GripShape::Triangle, Some(radial), - )); + ); + if is_surface { + draft_grip.dir = None; + } + grips.push(draft_grip); } } } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index fedae9365..fadc09389 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -23,6 +23,7 @@ fn display_tessellation( facet_resolution: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, ) -> brep::mesh::BodyMesh { let resolution = if facet_resolution.is_finite() && facet_resolution > 0.0 { facet_resolution.clamp(0.01, 10.0) @@ -34,7 +35,8 @@ fn display_tessellation( |_| cadkernel::tessellation::display_angle_for_resolution(resolution), ); let mut tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL) - .with_isolines(isolines); + .with_isolines(isolines) + .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); } @@ -267,20 +269,41 @@ pub fn edge_wires(body: &Body) -> Vec { .collect() } -/// White wireframe used while a solid-history grip is hot. +/// White wireframe used while a solid-history grip is hot. Surface previews +/// include the requested construction isolines so their cage follows the edit. /// /// This deliberately does not touch the resident solid mesh or its entity /// wires: the selected source stays visible in blue while the candidate body /// is presented as a separate, non-pickable outline until placement. -pub fn grip_preview_wires(body: &Body, handle: acadrust::Handle) -> Vec { - tessellation(body) +pub fn grip_preview_wires( + body: &Body, + handle: acadrust::Handle, + isolines: usize, +) -> Vec { + let tessellation = brep::mesh::tessellate( + body, + brep::mesh::TessellationTolerance::new( + cadkernel::tessellation::DEFAULT_ANGLE, + TOL, + ) + .with_isolines(isolines) + .with_planar_isolines(isolines > 0), + ); + tessellation .edges .into_iter() - .filter(|edge| edge.positions.len() >= 2) - .map(|edge| { + .map(|edge| edge.positions) + .chain( + tessellation + .isolines + .into_iter() + .map(|isoline| isoline.positions), + ) + .filter(|positions| positions.len() >= 2) + .map(|positions| { WireModel::solid_f64( format!("{}-GRIP-PREVIEW", handle.value()), - edge.positions, + positions, WireModel::WHITE, false, ) @@ -454,6 +477,7 @@ pub fn display_from_solid( facet_resolution: f64, chordal_deflection: Option, isolines: usize, + planar_isolines: bool, ) -> Option<(MeshLodSet, Vec, [f64; 3])> { use acadrust::types::Vector3; let tessellation = display_tessellation( @@ -461,6 +485,7 @@ pub fn display_from_solid( facet_resolution, chordal_deflection, isolines, + planar_isolines, ); let center = mesh_center(&tessellation.mesh)?; let wires = tessellation @@ -541,7 +566,7 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0, false) .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index 0b9fdc6a3..edd7195d5 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1043,7 +1043,15 @@ impl Scene { } self.solid_history_preview_wires.insert( handle, - crate::scene::model::solid_model::grip_preview_wires(&body, handle), + crate::scene::model::solid_model::grip_preview_wires( + &body, + handle, + if matches!(self.document.get_entity(handle), Some(EntityType::Surface(_))) { + self.document.header.isolines.max(0) as usize + } else { + 0 + }, + ), ); true } From 2b4d8fe046b58e3621c8686aed979be2156ae88c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:05 +0300 Subject: [PATCH 22/53] feat: add theme-aware expandable Draw panel --- build.rs | 25 +++++++++++ src/ui/ribbon/draw_panel.rs | 87 +++++++++++++++++++++++++++++++++++++ src/ui/ribbon/mod.rs | 9 +++- 3 files changed, 119 insertions(+), 2 deletions(-) create mode 100644 src/ui/ribbon/draw_panel.rs diff --git a/build.rs b/build.rs index fd50c581c..97135da56 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,7 @@ use std::path::Path; fn main() { + generate_draw_panel_tools(); let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); let parts: Vec<&str> = version.split('.').collect(); let app_version = if parts.len() == 3 && parts[0].len() == 4 @@ -81,3 +82,27 @@ fn main() { } } } + +/// Each drawing button can contribute its own expression without editing a registry. +fn generate_draw_panel_tools() { + let manifest = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo manifest directory")); + let directory = manifest.join("src/ui/ribbon/draw_tools"); + println!("cargo:rerun-if-changed={}", directory.display()); + let mut files = match std::fs::read_dir(&directory) { + Ok(entries) => entries.map(|entry| entry.expect("Draw tool directory entry").path()) + .filter(|path| path.is_file() && path.extension().is_some_and(|extension| extension == "rs")) + .collect::>(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), + Err(error) => panic!("Cannot read Draw tool contributions: {error}"), + }; + files.sort_by(|left, right| left.file_name().cmp(&right.file_name())); + let mut source = String::from("const TOOLS: &[Tool] = &[\n"); + for path in files { + println!("cargo:rerun-if-changed={}", path.display()); + // Debug string formatting produces a quoted Rust literal, including Windows escapes. + source.push_str(&format!(" include!({:?}),\n", path.to_str().expect("UTF-8 Draw tool path"))); + } + source.push_str("];\n"); + let output = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo output directory")); + std::fs::write(output.join("draw_panel_tools.rs"), source).expect("Write Draw tool registry"); +} diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs new file mode 100644 index 000000000..7bcf18c9b --- /dev/null +++ b/src/ui/ribbon/draw_panel.rs @@ -0,0 +1,87 @@ +//! Additional drawing tools, reached from the Draw panel's title. + +use std::time::Duration; + +use iced::widget::{button, column, container, row, scrollable, text, tooltip}; +use iced::{Element, Fill, Length, Theme}; + +use super::widgets::{make_icon, make_tip, muted_text_style, popup_panel_style, popup_row_style, tip_style, tool_btn_style}; +use super::{dropdown_backdrop, position_ribbon_dropdown, Ribbon}; +use crate::app::Message; +use crate::modules::IconKind; +use crate::t; +use crate::ui::{icons, wrap_bar::PosReport}; + +const PANEL_ID: &str = "draw_extension"; +const CELL: f32 = 36.0; +const GAP: f32 = 3.0; + +struct Tool { + command: &'static str, + label: &'static str, + icon: &'static [u8], + options: &'static [(&'static str, &'static str)], +} + +include!(concat!(env!("OUT_DIR"), "/draw_panel_tools.rs")); + +pub(super) fn owns_dropdown(id: &str) -> bool { + id == PANEL_ID || TOOLS.iter().any(|tool| tool.command == id && !tool.options.is_empty()) +} + +pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { + if title != "Draw" || TOOLS.is_empty() { + return container(text(t!(title)).size(9).style(muted_text_style)).padding([1, 4]).into(); + } + let expanded = open.as_deref().is_some_and(owns_dropdown); + let arrow = if expanded { icons::themed_arrow_up(7.0) } else { icons::themed_arrow_down(7.0) }; + PosReport::new(PANEL_ID, button(row![text(t!(title)).size(9), arrow].spacing(4).align_y(iced::Center)) + .on_press(Message::ToggleRibbonDropdown(PANEL_ID.to_string())) + .style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status)) + .padding([1, 4])).into() +} + +fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { + let face = button(make_icon(IconKind::Svg(tool.icon), 23.0)) + .on_press(Message::DropdownSelectItem { dropdown_id: PANEL_ID, cmd: tool.command }) + .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) + .width(if tool.options.is_empty() { CELL } else { CELL - 11.0 }) + .height(CELL) + .padding(3); + let tip = format!("{}\n{} {}", t!(tool.label), t!("Command:"), tool.command); + let face: Element<'static, Message> = tooltip(face, make_tip(tip), tooltip::Position::Bottom) + .delay(Duration::from_millis(400)).style(tip_style).into(); + if !tool.options.is_empty() { + row![face, button(icons::themed_arrow_down(6.0)) + .on_press(Message::ToggleRibbonDropdown(tool.command.to_string())) + .style(move |theme: &Theme, status| tool_btn_style(theme, false, status)) + .width(11).height(CELL).padding(2)].into() + } else { + face + } +} + +pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> { + let width = (7.0 * (CELL + GAP) - GAP + 12.0).min((win.0 - 8.0).max(CELL + 12.0)); + let (_, x, anchor_top) = ribbon.dd_anchor(PANEL_ID, width, win.0); + let anchor = crate::ui::wrap_bar::dropdown_bounds(PANEL_ID); + let left = anchor.map_or(x, |b| b.x).clamp(0.0, (win.0 - width).max(0.0)); + let top = anchor_top.min((win.1 - 80.0).max(0.0)); + let available_height = (win.1 - top - 4.0).max(1.0); + let contents: Element<'static, Message> = if let Some(tool) = TOOLS.iter().find(|tool| tool.command == id && !tool.options.is_empty()) { + column(tool.options.iter().map(|&(cmd, label)| { + button(text(t!(label)).size(11)) + .on_press(Message::DropdownSelectItem { dropdown_id: tool.command, cmd }) + .style(popup_row_style).width(Fill).padding([6, 10]).into() + }).collect::>>()).into() + } else { + let cols = (((width - 12.0 + GAP) / (CELL + GAP)).floor() as usize).clamp(1, 7); + column(TOOLS.chunks(cols).map(|tools| { + row(tools.iter().map(|tool| tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command))) + .collect::>()).spacing(GAP).into() + }).collect::>>()).spacing(GAP).padding(6).into() + }; + let panel = container(scrollable(contents).height(Length::Shrink)) + .max_height(available_height).width(width).style(popup_panel_style); + dropdown_backdrop(position_ribbon_dropdown(panel.into(), false, left, top)) +} diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index d4a61b697..2ef5e964e 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -21,6 +21,7 @@ use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; +mod draw_panel; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; @@ -391,7 +392,7 @@ impl Ribbon { pub fn select_dropdown_item(&mut self, dropdown_id: &'static str, cmd: &'static str) { self.last_cmd.insert(dropdown_id, cmd); - self.open_dropdown = None; + self.close_dropdown(); } // ── View ────────────────────────────────────────────────────────────── @@ -725,6 +726,10 @@ impl Ribbon { } let open_id = self.open_dropdown.as_deref()?; + if draw_panel::owns_dropdown(open_id) { + return Some(draw_panel::overlay(self, open_id, win)); + } + if open_id == UNDO_HISTORY_ID || open_id == REDO_HISTORY_ID { let is_undo = open_id == UNDO_HISTORY_ID; let labels = if is_undo { undo_labels } else { redo_labels }; @@ -1364,7 +1369,7 @@ fn render_group<'a>( column![ tools_el, - container(text(t!(group.title)).size(9).style(muted_text_style)).padding([1, 4]), + draw_panel::group_title(group.title, open_dd), ] .align_x(iced::Center) .spacing(0) From f7a240acd9c65745d450da87b2b7aa6200b74bf8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:05 +0300 Subject: [PATCH 23/53] feat: expose Spline Fit in the Draw expansion --- src/ui/ribbon/draw_tools/01_spline_fit.rs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/ui/ribbon/draw_tools/01_spline_fit.rs diff --git a/src/ui/ribbon/draw_tools/01_spline_fit.rs b/src/ui/ribbon/draw_tools/01_spline_fit.rs new file mode 100644 index 000000000..7eae11258 --- /dev/null +++ b/src/ui/ribbon/draw_tools/01_spline_fit.rs @@ -0,0 +1,6 @@ +Tool { + command: "SPLINE", + label: "Spline Fit", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/spline.svg")), + options: &[], +} From 306d998ebfbc61e1052f60c3fbd9485e98aaabcc Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:06 +0300 Subject: [PATCH 24/53] feat: expose Spline CV in the Draw expansion --- assets/icons/spline_cv.svg | 3 + src/app/commands/draw.rs | 8 +- src/modules/draw/draw/spline.rs | 125 ++++++++++++++++------- src/ui/ribbon/draw_tools/02_spline_cv.rs | 6 ++ 4 files changed, 103 insertions(+), 39 deletions(-) create mode 100644 assets/icons/spline_cv.svg create mode 100644 src/ui/ribbon/draw_tools/02_spline_cv.rs diff --git a/assets/icons/spline_cv.svg b/assets/icons/spline_cv.svg new file mode 100644 index 000000000..e26e9c610 --- /dev/null +++ b/assets/icons/spline_cv.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 6807abfa1..34d92e994 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -872,9 +872,13 @@ impl OpenCADStudio { self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } - "SPLINE" => { + "SPLINE" | "SPLINECV" => { use crate::modules::draw::draw::spline::SplineCommand; - let new_cmd = SplineCommand::new(); + let new_cmd = if cmd == "SPLINECV" { + SplineCommand::control_vertices() + } else { + SplineCommand::new() + }; self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); } diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index 97405c448..f143b638b 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -24,49 +24,75 @@ pub fn tool() -> ToolDef { pub struct SplineCommand { pts: Vec, + control_vertices: bool, + choosing_method: bool, } impl SplineCommand { pub fn new() -> Self { - Self { pts: Vec::new() } + Self { pts: Vec::new(), control_vertices: false, choosing_method: false } + } + + pub fn control_vertices() -> Self { + Self { control_vertices: true, ..Self::new() } } fn build(&self, closed: bool) -> Option { if self.pts.len() < 2 { return None; } - let fit_points = self - .pts - .iter() - .map(|p| Vector3::new(p.x, p.y, p.z)) - .collect(); - let mut spline = Spline { - degree: 3, - fit_points, - ..Default::default() - }; - spline.flags.closed = closed; - Some(EntityType::Spline(spline)) + Some(EntityType::Spline(make_spline(&self.pts, closed, self.control_vertices))) } } -/// Preview through the same fit-point representation that `build()` commits. -fn sample_curve(pts: &[DVec3], closed: bool) -> Vec<[f32; 3]> { +/// Store the chosen construction method directly in the persistent spline. +fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { + let mut points: Vec = pts + .iter() + .map(|p| Vector3::new(p.x, p.y, p.z)) + .collect(); + let mut spline = if control_vertices { + if closed && pts.first() != pts.last() { + points.push(points[0]); + } + let count = points.len(); + let degree = 3.min(count.saturating_sub(1)); + let spans = count - degree; + // Open uniform knot vector: degree + 1 equal knots at each endpoint. + let knots = (0..count + degree + 1) + .map(|index| { + if index <= degree { 0.0 } + else if index >= count { 1.0 } + else { (index - degree) as f64 / spans as f64 } + }) + .collect(); + Spline { + degree: degree as i32, + control_points: points, + knots, + weights: vec![1.0; count], + ..Default::default() + } + } else { + Spline { + degree: 3, + fit_points: points, + ..Default::default() + } + }; + spline.flags.closed = closed; + spline +} + +/// Preview the exact representation that `build()` commits. +fn sample_curve(pts: &[DVec3], closed: bool, control_vertices: bool) -> Vec<[f32; 3]> { if pts.len() < 2 { return pts .iter() .map(|p| [p.x as f32, p.y as f32, p.z as f32]) .collect(); } - let mut spline = Spline { - degree: 3, - fit_points: pts - .iter() - .map(|p| Vector3::new(p.x, p.y, p.z)) - .collect(), - ..Default::default() - }; - spline.flags.closed = closed; + let spline = make_spline(pts, closed, control_vertices); crate::entities::curve::spline_curve(&spline) .map(|curve| crate::entities::curve::curve_points(&curve)) .unwrap_or_else(|| crate::entities::spline::measurement_polyline(&spline)) @@ -77,11 +103,15 @@ fn sample_curve(pts: &[DVec3], closed: bool) -> Vec<[f32; 3]> { impl CadCommand for SplineCommand { fn name(&self) -> &'static str { - "SPLINE" + if self.control_vertices { "SPLINECV" } else { "SPLINE" } } fn prompt(&self) -> String { - if self.pts.is_empty() { + if self.choosing_method { + "SPLINE Choose creation method [Fit/Control vertices]:".into() + } else if self.pts.is_empty() && self.control_vertices { + t!("SPLINE Specify first control point:").into_owned() + } else if self.pts.is_empty() { t!("SPLINE Specify first point:").into_owned() } else { let n = self.pts.len(); @@ -91,8 +121,11 @@ impl CadCommand for SplineCommand { fn options(&self) -> Vec { use crate::command::CmdOption; + if self.choosing_method { + return vec![CmdOption::new("Fit", "FIT"), CmdOption::new("Control vertices", "CV")]; + } if self.pts.is_empty() { - return Vec::new(); + return vec![CmdOption::new(t!("Method").as_ref(), "M")]; } let mut opts = vec![CmdOption::new(t!("Close").as_ref(), "C")]; // Undo only makes sense once a control point exists. @@ -102,11 +135,18 @@ impl CadCommand for SplineCommand { } fn on_point(&mut self, pt: DVec3) -> CmdResult { + if self.choosing_method || !pt.is_finite() { + return CmdResult::NeedPoint; + } self.pts.push(pt); CmdResult::NeedPoint } fn on_enter(&mut self) -> CmdResult { + if self.choosing_method { + self.choosing_method = false; + return CmdResult::NeedPoint; + } match self.build(false) { Some(e) => CmdResult::CommitAndExit(e), None => CmdResult::Cancel, @@ -114,26 +154,37 @@ impl CadCommand for SplineCommand { } fn enter_accepts_default_start(&self) -> bool { - self.pts.is_empty() + self.pts.is_empty() && !self.choosing_method } fn on_escape(&mut self) -> CmdResult { - match self.build(false) { - Some(e) => CmdResult::CommitAndExit(e), - None => CmdResult::Cancel, - } + CmdResult::Cancel } fn wants_text_input(&self) -> bool { - !self.pts.is_empty() + true } fn point_step_accepts_keywords(&self) -> bool { - !self.pts.is_empty() + true } fn on_text_input(&mut self, text: &str) -> Option { match text.trim().to_uppercase().as_str() { + "M" | "METHOD" if self.pts.is_empty() => { + self.choosing_method = true; + Some(CmdResult::NeedPoint) + } + "F" | "FIT" if self.pts.is_empty() => { + self.control_vertices = false; + self.choosing_method = false; + Some(CmdResult::NeedPoint) + } + "CV" | "CONTROL" | "CONTROLVERTICES" if self.pts.is_empty() => { + self.control_vertices = true; + self.choosing_method = false; + Some(CmdResult::NeedPoint) + } "C" | "CLOSE" => match self.build(true) { Some(e) => Some(CmdResult::CommitAndExit(e)), None => Some(CmdResult::NeedPoint), @@ -150,12 +201,12 @@ impl CadCommand for SplineCommand { if self.pts.is_empty() { return None; } - // Preview the committed fit-point representation. + // Preview the committed construction method. let mut ctrl = self.pts.clone(); ctrl.push(pt); Some(WireModel::solid( "rubber_band".into(), - sample_curve(&ctrl, false), + sample_curve(&ctrl, false, self.control_vertices), WireModel::CYAN, false, )) @@ -164,4 +215,4 @@ impl CadCommand for SplineCommand { // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["SPLINE"] }); // SplineCommand +inventory::submit!(crate::command::CommandRegistration { names: &["SPLINE", "SPLINECV"] }); // SplineCommand diff --git a/src/ui/ribbon/draw_tools/02_spline_cv.rs b/src/ui/ribbon/draw_tools/02_spline_cv.rs new file mode 100644 index 000000000..ddb30c5e2 --- /dev/null +++ b/src/ui/ribbon/draw_tools/02_spline_cv.rs @@ -0,0 +1,6 @@ +Tool { + command: "SPLINECV", + label: "Spline CV", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/spline_cv.svg")), + options: &[], +} From dc83a3e5fa1e8d2e3cc2cf980efd9a4b0214fc0d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:07 +0300 Subject: [PATCH 25/53] feat: expose Construction Line in the Draw expansion --- assets/icons/xline.svg | 3 +++ src/modules/draw/draw/ray.rs | 1 - src/ui/ribbon/draw_tools/03_xline.rs | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 assets/icons/xline.svg create mode 100644 src/ui/ribbon/draw_tools/03_xline.rs diff --git a/assets/icons/xline.svg b/assets/icons/xline.svg new file mode 100644 index 000000000..7b02665ef --- /dev/null +++ b/assets/icons/xline.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 0b349b42e..4d976acd9 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -167,7 +167,6 @@ impl CadCommand for XLineCommand { Vector3::new(base.x, base.y, base.z), Vector3::new(dir_n.x, dir_n.y, dir_n.z), ); - self.base = None; CmdResult::CommitEntity(EntityType::XLine(xline)) } else { self.base = Some(pt); diff --git a/src/ui/ribbon/draw_tools/03_xline.rs b/src/ui/ribbon/draw_tools/03_xline.rs new file mode 100644 index 000000000..65e5138f8 --- /dev/null +++ b/src/ui/ribbon/draw_tools/03_xline.rs @@ -0,0 +1,6 @@ +Tool { + command: "XLINE", + label: "Construction Line", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/xline.svg")), + options: &[], +} From cc503f18477fc1223f50b7d219bd4a663a5621c4 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:07 +0300 Subject: [PATCH 26/53] feat: expose Ray in the Draw expansion --- assets/icons/ray.svg | 3 +++ src/modules/draw/draw/ray.rs | 4 +--- src/ui/ribbon/draw_tools/04_ray.rs | 6 ++++++ 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 assets/icons/ray.svg create mode 100644 src/ui/ribbon/draw_tools/04_ray.rs diff --git a/assets/icons/ray.svg b/assets/icons/ray.svg new file mode 100644 index 000000000..9a560c793 --- /dev/null +++ b/assets/icons/ray.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 0b349b42e..725ffaed8 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -61,9 +61,7 @@ impl CadCommand for RayCommand { Vector3::new(base.x, base.y, base.z), Vector3::new(dir_n.x, dir_n.y, dir_n.z), ); - // Stay active: new base = same base (can keep clicking through points) - // Actually AutoCAD prompts for new start after each ray — reset base. - self.base = None; + // Repeated through points share the original start point. CmdResult::CommitEntity(EntityType::Ray(ray)) } else { self.base = Some(pt); diff --git a/src/ui/ribbon/draw_tools/04_ray.rs b/src/ui/ribbon/draw_tools/04_ray.rs new file mode 100644 index 000000000..3fc739731 --- /dev/null +++ b/src/ui/ribbon/draw_tools/04_ray.rs @@ -0,0 +1,6 @@ +Tool { + command: "RAY", + label: "Ray", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/ray.svg")), + options: &[], +} From 0f81166c3acc7033091602b88be908ffeded2860 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:08 +0300 Subject: [PATCH 27/53] feat: expose Multiple Points in the Draw expansion --- assets/icons/multipoint.svg | 3 +++ src/ui/ribbon/draw_tools/05_multipoint.rs | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 assets/icons/multipoint.svg create mode 100644 src/ui/ribbon/draw_tools/05_multipoint.rs diff --git a/assets/icons/multipoint.svg b/assets/icons/multipoint.svg new file mode 100644 index 000000000..a5443446e --- /dev/null +++ b/assets/icons/multipoint.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/ribbon/draw_tools/05_multipoint.rs b/src/ui/ribbon/draw_tools/05_multipoint.rs new file mode 100644 index 000000000..c2a3445e6 --- /dev/null +++ b/src/ui/ribbon/draw_tools/05_multipoint.rs @@ -0,0 +1,6 @@ +Tool { + command: "MULTIPOINT", + label: "Multiple Points", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/multipoint.svg")), + options: &[("POINT", "Single Point"), ("MULTIPOINT", "Multiple Points"), ("DDPTYPE", "Point Style")], +} From 894058fa60cac91598bdd23a804b6eff55eced65 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:08 +0300 Subject: [PATCH 28/53] feat: expose Divide in the Draw expansion --- assets/icons/divide.svg | 3 +++ src/ui/ribbon/draw_tools/06_divide.rs | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 assets/icons/divide.svg create mode 100644 src/ui/ribbon/draw_tools/06_divide.rs diff --git a/assets/icons/divide.svg b/assets/icons/divide.svg new file mode 100644 index 000000000..024546aac --- /dev/null +++ b/assets/icons/divide.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/ribbon/draw_tools/06_divide.rs b/src/ui/ribbon/draw_tools/06_divide.rs new file mode 100644 index 000000000..9ea3d44a3 --- /dev/null +++ b/src/ui/ribbon/draw_tools/06_divide.rs @@ -0,0 +1,6 @@ +Tool { + command: "DIVIDE", + label: "Divide", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/divide.svg")), + options: &[], +} From 1abfb306593e28f47083e5d74bf2d92cb527cfe8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:09 +0300 Subject: [PATCH 29/53] feat: expose Measure in the Draw expansion --- assets/icons/measure.svg | 3 +++ src/ui/ribbon/draw_tools/07_measure.rs | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 assets/icons/measure.svg create mode 100644 src/ui/ribbon/draw_tools/07_measure.rs diff --git a/assets/icons/measure.svg b/assets/icons/measure.svg new file mode 100644 index 000000000..8ffa45c20 --- /dev/null +++ b/assets/icons/measure.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/ribbon/draw_tools/07_measure.rs b/src/ui/ribbon/draw_tools/07_measure.rs new file mode 100644 index 000000000..359b1afc8 --- /dev/null +++ b/src/ui/ribbon/draw_tools/07_measure.rs @@ -0,0 +1,6 @@ +Tool { + command: "MEASURE", + label: "Measure", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/measure.svg")), + options: &[], +} From c50610e37b9f52e2859a461feacd5728109a3a1f Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:09 +0300 Subject: [PATCH 30/53] feat: expose Region in the Draw expansion --- assets/icons/region.svg | 3 +++ src/app/commands/draw.rs | 7 +++++++ src/ui/ribbon/draw_tools/08_region.rs | 6 ++++++ 3 files changed, 16 insertions(+) create mode 100644 assets/icons/region.svg create mode 100644 src/ui/ribbon/draw_tools/08_region.rs diff --git a/assets/icons/region.svg b/assets/icons/region.svg new file mode 100644 index 000000000..fa9a6d339 --- /dev/null +++ b/assets/icons/region.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 6807abfa1..b031fda55 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1110,6 +1110,13 @@ impl OpenCADStudio { // REGION — convert selected closed boundaries (closed polylines / // circles) into Region entities (one wire loop each). "REGION" | "REG" => { + if self.tabs[i].scene.selected_entities().is_empty() { + use crate::modules::draw::select::SelectObjectsCommand; + let command = SelectObjectsCommand::new("REGION"); + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); + return Some(iced::Task::none()); + } use acadrust::entities::Region; use acadrust::types::Vector3; let mut regions = Vec::new(); diff --git a/src/ui/ribbon/draw_tools/08_region.rs b/src/ui/ribbon/draw_tools/08_region.rs new file mode 100644 index 000000000..a09ec4dea --- /dev/null +++ b/src/ui/ribbon/draw_tools/08_region.rs @@ -0,0 +1,6 @@ +Tool { + command: "REGION", + label: "Region", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/region.svg")), + options: &[], +} From 6c102a841e6bb225708db688da6b9749a7939cdf Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:09 +0300 Subject: [PATCH 31/53] feat: expose Wipeout in the Draw expansion --- src/ui/ribbon/draw_tools/09_wipeout.rs | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/ui/ribbon/draw_tools/09_wipeout.rs diff --git a/src/ui/ribbon/draw_tools/09_wipeout.rs b/src/ui/ribbon/draw_tools/09_wipeout.rs new file mode 100644 index 000000000..ba3210262 --- /dev/null +++ b/src/ui/ribbon/draw_tools/09_wipeout.rs @@ -0,0 +1,6 @@ +Tool { + command: "WIPEOUT", + label: "Wipeout", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/wipeout.svg")), + options: &[], +} From 274706ee99f63e677d380b6b3d2437640f25e993 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:10 +0300 Subject: [PATCH 32/53] feat: expose Boundary in the Draw expansion --- assets/icons/boundary.svg | 3 +++ src/ui/ribbon/draw_tools/10_boundary.rs | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 assets/icons/boundary.svg create mode 100644 src/ui/ribbon/draw_tools/10_boundary.rs diff --git a/assets/icons/boundary.svg b/assets/icons/boundary.svg new file mode 100644 index 000000000..61848abfd --- /dev/null +++ b/assets/icons/boundary.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/ribbon/draw_tools/10_boundary.rs b/src/ui/ribbon/draw_tools/10_boundary.rs new file mode 100644 index 000000000..395ecb34f --- /dev/null +++ b/src/ui/ribbon/draw_tools/10_boundary.rs @@ -0,0 +1,6 @@ +Tool { + command: "BOUNDARY", + label: "Boundary", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/boundary.svg")), + options: &[], +} From cf34df40616cc2b22dfcf5b6968177d0856fab9d Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:10 +0300 Subject: [PATCH 33/53] feat: expose Helix in the Draw expansion --- assets/icons/helix.svg | 3 +++ src/ui/ribbon/draw_tools/11_helix.rs | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 assets/icons/helix.svg create mode 100644 src/ui/ribbon/draw_tools/11_helix.rs diff --git a/assets/icons/helix.svg b/assets/icons/helix.svg new file mode 100644 index 000000000..bee0234bb --- /dev/null +++ b/assets/icons/helix.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/ribbon/draw_tools/11_helix.rs b/src/ui/ribbon/draw_tools/11_helix.rs new file mode 100644 index 000000000..844397765 --- /dev/null +++ b/src/ui/ribbon/draw_tools/11_helix.rs @@ -0,0 +1,6 @@ +Tool { + command: "HELIX", + label: "Helix", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/helix.svg")), + options: &[], +} From 860495091290935855dfa153cbe2b16284a85ab2 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:10 +0300 Subject: [PATCH 34/53] feat: expose Donut in the Draw expansion --- assets/icons/donut.svg | 3 +++ src/ui/ribbon/draw_tools/12_donut.rs | 6 ++++++ 2 files changed, 9 insertions(+) create mode 100644 assets/icons/donut.svg create mode 100644 src/ui/ribbon/draw_tools/12_donut.rs diff --git a/assets/icons/donut.svg b/assets/icons/donut.svg new file mode 100644 index 000000000..8e2b26155 --- /dev/null +++ b/assets/icons/donut.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/ui/ribbon/draw_tools/12_donut.rs b/src/ui/ribbon/draw_tools/12_donut.rs new file mode 100644 index 000000000..3761f1b61 --- /dev/null +++ b/src/ui/ribbon/draw_tools/12_donut.rs @@ -0,0 +1,6 @@ +Tool { + command: "DONUT", + label: "Donut", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/donut.svg")), + options: &[], +} From 786e31e3a78341916e209c9d513319c7ae5a8bff Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:55:11 +0300 Subject: [PATCH 35/53] feat: expose Revision Cloud in the Draw expansion --- src/app/commands/draw.rs | 11 +++++++---- src/modules/draw/draw/revcloud.rs | 4 +++- src/ui/ribbon/draw_tools/13_revcloud.rs | 10 ++++++++++ 3 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 src/ui/ribbon/draw_tools/13_revcloud.rs diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index 6807abfa1..eba3d682d 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -62,7 +62,7 @@ impl OpenCADStudio { return Some(Task::done(Message::ImagePick)); } - "REVCLOUD" => { + "REVCLOUD" | "REVCLOUD_RECTANGULAR" | "REVCLOUD_POLYGONAL" | "REVCLOUD_FREEHAND" => { use crate::modules::draw::draw::revcloud::RevCloudCommand; let view_height = self.tabs[i].scene.camera.borrow().ortho_size() as f64 * 2.0; let default_arc_length = (view_height * 0.0125).max(1.0e-6); @@ -72,9 +72,12 @@ impl OpenCADStudio { .entities() .map(|entity| (entity.common().handle, entity.clone())) .collect(); - let cmd = RevCloudCommand::new(default_arc_length, sources); - self.command_line.push_info(&cmd.prompt()); - self.tabs[i].active_cmd = Some(Box::new(cmd)); + let mut command = RevCloudCommand::new(default_arc_length, sources); + if let Some(mode) = cmd.strip_prefix("REVCLOUD_") { + command.on_text_input(mode); + } + self.command_line.push_info(&command.prompt()); + self.tabs[i].active_cmd = Some(Box::new(command)); } "ATTDEF" => { diff --git a/src/modules/draw/draw/revcloud.rs b/src/modules/draw/draw/revcloud.rs index 8da3960c7..516f0e23a 100644 --- a/src/modules/draw/draw/revcloud.rs +++ b/src/modules/draw/draw/revcloud.rs @@ -933,4 +933,6 @@ fn style_from_entity(entity: &EntityType) -> Option { }) } -inventory::submit!(crate::command::CommandRegistration { names: &["REVCLOUD"] }); +inventory::submit!(crate::command::CommandRegistration { + names: &["REVCLOUD", "REVCLOUD_RECTANGULAR", "REVCLOUD_POLYGONAL", "REVCLOUD_FREEHAND"] +}); diff --git a/src/ui/ribbon/draw_tools/13_revcloud.rs b/src/ui/ribbon/draw_tools/13_revcloud.rs new file mode 100644 index 000000000..cedb4a4f5 --- /dev/null +++ b/src/ui/ribbon/draw_tools/13_revcloud.rs @@ -0,0 +1,10 @@ +Tool { + command: "REVCLOUD", + label: "Revision Cloud", + icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/revcloud.svg")), + options: &[ + ("REVCLOUD_RECTANGULAR", "Rectangular"), + ("REVCLOUD_POLYGONAL", "Polygonal"), + ("REVCLOUD_FREEHAND", "Freehand"), + ], +} From 6b58717ce2f9b36991f467802956c4afd872efb7 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:58:06 +0300 Subject: [PATCH 36/53] fix: release collapsed flyout before opening Draw tools --- src/ui/ribbon/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 2ef5e964e..e0f2b2c49 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -340,6 +340,9 @@ impl Ribbon { self.open_dropdown = None; } else { self.open_dropdown = Some(id.to_string()); + if draw_panel::owns_dropdown(id) { + self.collapsed_open = None; + } } } pub fn close_dropdown(&mut self) { From 11057cc5e980284d587b8da9a7963860a7501193 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:07:05 +0300 Subject: [PATCH 37/53] Fix extrusion surface properties and snapping --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/properties.rs | 33 +++-- src/app/update/command.rs | 43 ++++++- src/entities/solid3d.rs | 84 ++++++++++++- src/modules/insert/solid3d_cmds.rs | 2 + src/modules/model/edge_cmd.rs | 7 +- src/scene/convert/acis_kernel.rs | 4 +- src/scene/convert/solid3d_tess.rs | 15 +-- src/scene/mod.rs | 12 +- src/scene/model/solid_history.rs | 190 ++++++++++++++++++++++++++--- src/scene/model/solid_model.rs | 15 +-- src/scene/modify.rs | 14 ++- src/snap.rs | 13 ++ 15 files changed, 383 insertions(+), 55 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d77046e7b..21c353fd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=c584d19dc45b3bef5146d6cf2e3dae79b72e9468#c584d19dc45b3bef5146d6cf2e3dae79b72e9468" +source = "git+https://github.com/ramox81/cadkernel.git?rev=d7431a6ab8600454ab3ad78ad7674576cbc2d5db#d7431a6ab8600454ab3ad78ad7674576cbc2d5db" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index f7a8295a2..325321206 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "c584d19dc45b3bef5146d6cf2e3dae79b72e9468", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "d7431a6ab8600454ab3ad78ad7674576cbc2d5db", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 4c902ed91..e673dc1fb 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "c584d19dc45b3bef5146d6cf2e3dae79b72e9468", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "d7431a6ab8600454ab3ad78ad7674576cbc2d5db", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/properties.rs b/src/app/properties.rs index 8fd1faec1..61742e50f 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2142,6 +2142,17 @@ impl OpenCADStudio { entity_type_label(entity) } } + acadrust::EntityType::Surface(_) + if matches!( + crate::scene::model::solid_history::primitive_property_operation( + &self.tabs[i].scene.document, + handle, + ), + Some(acadrust::objects::SolidHistoryOperation::Extrusion(_)) + ) => + { + format!("{} ({})", t!("Surface"), t!("Extrusion")) + } _ => entity_type_label(entity), }; ui::PropertiesPanel { @@ -2249,23 +2260,22 @@ filter={:.1} local={:.1} aggregate={:.1} entities={}", ); } } - if local_refs.iter().all(|(handle, _)| { + let compact_solids = local_refs.iter().all(|(handle, _)| { crate::scene::model::solid_history::has_compact_solid_properties( &self.tabs[i].scene.document, *handle, ) - }) { - sections.retain(|section| { - !section.props.iter().any(|property| { - property.field.starts_with("acis_") - || property.field.starts_with("s3d_") - }) - }); + }); + if compact_solids { + retain_compact_solid_sections(&mut sections); } sections.extend(aggregate_solid_history_sections( &self.tabs[i].scene.document, &local_refs.iter().map(|(handle, _)| *handle).collect::>(), )); + if compact_solids { + retain_compact_solid_sections(&mut sections); + } ui::PropertiesPanel { choice_combos: sections .iter() @@ -3205,6 +3215,10 @@ fn retain_compact_solid_sections( sections: &mut Vec, ) { sections.iter_mut().for_each(|section| { + let internal_surface_section = section + .props + .iter() + .any(|property| property.field == "srf_kind"); section.props.retain(|property| { matches!( property.field, @@ -3217,7 +3231,8 @@ fn retain_compact_solid_sections( | "transparency" | "hyperlink" | "material" - ) || crate::scene::model::solid_history::is_specialized_property(property.field) + ) || (!internal_surface_section + && crate::scene::model::solid_history::is_specialized_property(property.field)) }); }); sections.retain(|section| !section.props.is_empty()); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index c91e0f3e6..4b37b2e42 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2002,7 +2002,48 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } self.push_undo_snapshot(i, "CHPROP"); - if crate::scene::model::solid_history::is_loft_geometry_choice(field) { + if crate::scene::model::solid_history::is_surface_property_choice(field) { + use crate::scene::model::solid_history::{ + PROP_SURFACE_MAINTAIN_ASSOCIATIVITY, PROP_SURFACE_SHOW_ASSOCIATIVITY, + PROP_SURFACE_WIREFRAME_TYPE, + }; + for &handle in &handles { + if self.tabs[i].scene.is_layer_locked(handle) { + continue; + } + let Some(mut state) = self.tabs[i] + .scene + .document + .get_entity(handle) + .and_then(|entity| match entity { + acadrust::EntityType::Surface(surface) => Some( + crate::entities::solid3d::surface_property_state(surface), + ), + _ => None, + }) + else { + continue; + }; + match field { + PROP_SURFACE_WIREFRAME_TYPE => { + state.isolines = !value.eq_ignore_ascii_case("Isoparms") + } + PROP_SURFACE_MAINTAIN_ASSOCIATIVITY => { + state.maintain_associativity = value.eq_ignore_ascii_case("Yes") + } + PROP_SURFACE_SHOW_ASSOCIATIVITY => { + state.show_associativity = value.eq_ignore_ascii_case("Yes") + } + _ => continue, + } + crate::scene::view::dispatch::set_entity_xdata( + &mut self.tabs[i].scene.document, + handle, + crate::entities::solid3d::SURFACE_PROPERTIES_APP, + Some(crate::entities::solid3d::surface_property_xdata_values(state)), + ); + } + } else if crate::scene::model::solid_history::is_loft_geometry_choice(field) { // These choices change the generated body, not just history // flags. Use the same transactional rebuild as numeric edits. for &handle in &handles { diff --git a/src/entities/solid3d.rs b/src/entities/solid3d.rs index ce06f0b5a..8cf4f1924 100644 --- a/src/entities/solid3d.rs +++ b/src/entities/solid3d.rs @@ -3,6 +3,7 @@ // Shared grips and properties for modeler entities. use acadrust::entities::{Body, Region, Solid3D, Surface}; +use acadrust::xdata::{ExtendedDataRecord, XDataValue}; use cadkernel::space::polygon; use crate::t; use crate::command::EntityTransform; @@ -49,6 +50,71 @@ fn yes_no(value: bool) -> &'static str { if value { "Yes" } else { "No" } } +pub(crate) const SURFACE_PROPERTIES_APP: &str = "OCS_SURFACE_PROPERTIES"; + +#[derive(Clone, Copy)] +pub(crate) struct SurfacePropertyState { + pub isolines: bool, + pub maintain_associativity: bool, + pub show_associativity: bool, +} + +pub(crate) fn surface_property_state(surface: &Surface) -> SurfacePropertyState { + let defaults = SurfacePropertyState { + isolines: true, + maintain_associativity: surface.history_handle.is_some(), + show_associativity: false, + }; + let Some(record) = surface + .common + .extended_data + .get_record(SURFACE_PROPERTIES_APP) + else { + return defaults; + }; + let mut values = record.values.iter().filter_map(|value| match value { + XDataValue::Integer16(value) => Some(*value), + _ => None, + }); + let Some(version) = values.next() else { + return defaults; + }; + if version != 1 { + return defaults; + } + SurfacePropertyState { + isolines: values.next().map_or(defaults.isolines, |value| value == 0), + maintain_associativity: values + .next() + .map_or(defaults.maintain_associativity, |value| value != 0), + show_associativity: values + .next() + .map_or(defaults.show_associativity, |value| value != 0), + } +} + +pub(crate) fn surface_property_xdata_values(state: SurfacePropertyState) -> Vec { + vec![ + XDataValue::Integer16(1), + XDataValue::Integer16(if state.isolines { 0 } else { 1 }), + XDataValue::Integer16(state.maintain_associativity as i16), + XDataValue::Integer16(state.show_associativity as i16), + ] +} + +fn write_surface_property_state(surface: &mut Surface, state: SurfacePropertyState) { + let mut record = ExtendedDataRecord::new(SURFACE_PROPERTIES_APP); + record.values = surface_property_xdata_values(state); + surface.common.extended_data.upsert_record(record); +} + +pub(crate) fn surface_isoline_counts(surface: &Surface) -> [usize; 2] { + [ + surface.u_isolines.max(0) as usize, + surface.v_isolines.max(0) as usize, + ] +} + fn handle_text(handle: Option) -> String { handle .filter(|handle| handle.is_valid()) @@ -700,6 +766,21 @@ impl PropertyEditable for Surface { fn apply_geom_prop(&mut self, field: &str, value: &str) { match field { + "srf_wireframe_type" => { + let mut state = surface_property_state(self); + state.isolines = !value.eq_ignore_ascii_case("Isoparms"); + write_surface_property_state(self, state); + } + "srf_maintain_associativity" => { + let mut state = surface_property_state(self); + state.maintain_associativity = value.eq_ignore_ascii_case("Yes"); + write_surface_property_state(self, state); + } + "srf_show_associativity" => { + let mut state = surface_property_state(self); + state.show_associativity = value.eq_ignore_ascii_case("Yes"); + write_surface_property_state(self, state); + } "srf_u_isolines" => { if let Some(value) = parse_f64(value) { self.u_isolines = (value.round() as i16).max(0); @@ -822,7 +903,8 @@ pub fn tessellate_volume( color, facet_res, chordal_deflection, - isolines, + surface_isoline_counts(s), + surface_property_state(s).isolines, ), EntityType::Mesh(_) | EntityType::PolygonMesh(_) | EntityType::PolyfaceMesh(_) => { crate::entities::mesh::tessellate_shaded_mesh(e, color) diff --git a/src/modules/insert/solid3d_cmds.rs b/src/modules/insert/solid3d_cmds.rs index 3f5788237..5e4d5d129 100644 --- a/src/modules/insert/solid3d_cmds.rs +++ b/src/modules/insert/solid3d_cmds.rs @@ -2778,6 +2778,8 @@ pub fn empty_extruded_surface(direction: DVec3, taper_angle: f64) -> EntityType use acadrust::types::Vector3; let mut surface = Surface::new(SurfaceKind::Extruded); + surface.u_isolines = 6; + surface.v_isolines = 6; if let SurfaceData::Extruded { options, sweep_vector, diff --git a/src/modules/model/edge_cmd.rs b/src/modules/model/edge_cmd.rs index 59a8a9c4b..08dfda16e 100644 --- a/src/modules/model/edge_cmd.rs +++ b/src/modules/model/edge_cmd.rs @@ -210,7 +210,12 @@ impl SolidEdgeCommand { ) .ok()?, }; - let mut wires = crate::scene::model::solid_model::grip_preview_wires(&result, handle, 0); + let mut wires = crate::scene::model::solid_model::grip_preview_wires( + &result, + handle, + [0; 2], + false, + ); for wire in &mut wires { wire.color = self.preview_color; wire.name = format!("{}-{}-PREVIEW", handle.value(), self.name()); diff --git a/src/scene/convert/acis_kernel.rs b/src/scene/convert/acis_kernel.rs index 4edc63268..29fb8b613 100644 --- a/src/scene/convert/acis_kernel.rs +++ b/src/scene/convert/acis_kernel.rs @@ -24,7 +24,7 @@ pub fn tessellate_sat( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, ) -> Option { let (bodies, loss) = lift(document); @@ -115,7 +115,7 @@ pub fn tessellate_sat( max_angle, source_fit * placement_scale, ) - .with_isolines(isolines) + .with_uv_isolines(isolines[0], isolines[1]) .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); diff --git a/src/scene/convert/solid3d_tess.rs b/src/scene/convert/solid3d_tess.rs index 3015b4a7f..682bfd3c0 100644 --- a/src/scene/convert/solid3d_tess.rs +++ b/src/scene/convert/solid3d_tess.rs @@ -10,7 +10,7 @@ fn tessellate_acis( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, ) -> Option { crate::scene::convert::acis_kernel::tessellate_sat( @@ -220,7 +220,7 @@ fn finish( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, acis: &acadrust::entities::AcisData, ) -> Option { @@ -255,7 +255,7 @@ pub fn tessellate_region( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], false, ®ion.acis_data, ) @@ -279,7 +279,7 @@ pub fn tessellate_body( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], false, &body.acis_data, ) @@ -290,7 +290,8 @@ pub fn tessellate_surface( color: [f32; 4], facet_res: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> Option { let sat = parse_acis( || surface.parse_sat(), @@ -304,7 +305,7 @@ pub fn tessellate_surface( facet_res, chordal_deflection, isolines, - true, + planar_isolines, &surface.acis_data, ) } @@ -327,7 +328,7 @@ pub fn tessellate_solid3d( color, facet_res, chordal_deflection, - isolines, + [isolines; 2], false, &solid.acis_data, ) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index bd2cf4fb0..715e6bb5d 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3082,11 +3082,13 @@ impl Scene { let facet_resolution = self.document.header.facet_resolution; let chordal_deflection = crate::entities::solid3d::display_deflection(&self.document.header, facet_resolution); - let isolines = self.document.header.isolines.max(0) as usize; - let planar_isolines = matches!( - self.document.get_entity(handle), - Some(EntityType::Surface(_)) - ); + let (isolines, planar_isolines) = match self.document.get_entity(handle) { + Some(EntityType::Surface(surface)) => ( + crate::entities::solid3d::surface_isoline_counts(surface), + crate::entities::solid3d::surface_property_state(surface).isolines, + ), + _ => ([self.document.header.isolines.max(0) as usize; 2], false), + }; let (mut set, wires, center) = crate::scene::model::solid_model::display_from_solid( solid, diff --git a/src/scene/model/solid_history.rs b/src/scene/model/solid_history.rs index ba6027255..45eafe9e7 100644 --- a/src/scene/model/solid_history.rs +++ b/src/scene/model/solid_history.rs @@ -94,6 +94,14 @@ pub const PROP_LOFT_CLOSED: &str = "solid_history_loft_closed"; pub const PROP_LOFT_PERIODIC: &str = "solid_history_loft_periodic"; pub const PROP_HISTORY: &str = "solid_history_record"; pub const PROP_SHOW_HISTORY: &str = "solid_history_show"; +pub const PROP_SURFACE_TYPE: &str = "surface_type"; +pub const PROP_SURFACE_WIREFRAME_TYPE: &str = "srf_wireframe_type"; +pub const PROP_SURFACE_U_ISOLINES: &str = "srf_u_isolines"; +pub const PROP_SURFACE_V_ISOLINES: &str = "srf_v_isolines"; +pub const PROP_SURFACE_MAINTAIN_ASSOCIATIVITY: &str = "srf_maintain_associativity"; +pub const PROP_SURFACE_SHOW_ASSOCIATIVITY: &str = "srf_show_associativity"; +pub const PROP_SURFACE_TRIMMED: &str = "srf_trimmed"; +pub const PROP_SURFACE_TRIMMING_EDGES: &str = "srf_trimming_edges"; fn history_prop(label: &str, field: &'static str, value: impl ToString) -> Property { Property { @@ -103,6 +111,23 @@ fn history_prop(label: &str, field: &'static str, value: impl ToString) -> Prope } } +fn compact_surface_number(mut value: String) -> String { + if value.ends_with('°') { + value.pop(); + } + if let Some(decimal) = value.rfind('.') { + if value[decimal + 1..].chars().all(|character| character.is_ascii_digit()) { + while value.ends_with('0') { + value.pop(); + } + if value.ends_with('.') { + value.pop(); + } + } + } + value +} + fn history_flags( document: &acadrust::CadDocument, handle: acadrust::Handle, @@ -239,9 +264,14 @@ pub fn has_compact_solid_properties( document: &acadrust::CadDocument, handle: acadrust::Handle, ) -> bool { - document - .get_entity(handle) - .is_some_and(|entity| matches!(entity, acadrust::EntityType::Solid3D(_))) + match document.get_entity(handle) { + Some(acadrust::EntityType::Solid3D(_)) => true, + Some(acadrust::EntityType::Surface(_)) => matches!( + primitive_property_operation(document, handle), + Some(SolidHistoryOperation::Extrusion(_)) + ), + _ => false, + } } pub fn reference_point(operation: &SolidHistoryOperation) -> Option { @@ -403,10 +433,6 @@ fn extrusion_properties( |direction| crate::entities::common::format_length(direction[axis]), )), }; - let (record_history, object_show_history, show_history_mode) = - history_flags(document, handle).unwrap_or((false, false, 1)); - let (show_history, show_history_editable) = - displayed_history_state(object_show_history, show_history_mode); let path_length = sweep_path_length(value); let height_value = path_length.unwrap_or(value.end_draft_distance); let height = if value.path_entity.is_some() { @@ -419,6 +445,134 @@ fn extrusion_properties( } else { PropValue::EditText(crate::entities::common::format_angle(value.draft_angle)) }; + if let Some(EntityType::Surface(surface)) = document.get_entity(handle) { + let state = crate::entities::solid3d::surface_property_state(surface); + let surface_height = if value.path_entity.is_some() { + PropValue::ReadOnly(compact_surface_number( + crate::entities::common::format_length(height_value), + )) + } else { + PropValue::EditText(compact_surface_number( + crate::entities::common::format_length(height_value), + )) + }; + let surface_taper = if value.path_entity.is_some() { + PropValue::ReadOnly(compact_surface_number( + crate::entities::common::format_angle(value.draft_angle), + )) + } else { + PropValue::EditText(compact_surface_number( + crate::entities::common::format_angle(value.draft_angle), + )) + }; + let surface_direction_property = + |label: &str, field: &'static str, axis: usize| Property { + label: label.to_string(), + field, + value: PropValue::ReadOnly(direction.map_or_else( + || t!("Unavailable").into_owned(), + |direction| { + compact_surface_number(crate::entities::common::format_length( + direction[axis], + )) + }, + )), + }; + let yes_no_choice = |selected: bool| PropValue::Choice { + selected: if selected { "Yes" } else { "No" }.to_string(), + options: vec!["Yes".to_string(), "No".to_string()], + }; + return vec![ + PropSection { + title: t!("Geometry").into_owned(), + props: vec![ + Property { + label: t!("Surface Type").into_owned(), + field: PROP_SURFACE_TYPE, + value: PropValue::ReadOnly(t!("Extrusion").into_owned()), + }, + Property { + label: t!("Height").into_owned(), + field: PROP_EXTRUSION_HEIGHT, + value: surface_height, + }, + Property { + label: t!("Taper angle").into_owned(), + field: PROP_TAPER_ANGLE, + value: surface_taper, + }, + surface_direction_property( + t!("Direction X").as_ref(), + PROP_EXTRUSION_DIRECTION_X, + 0, + ), + surface_direction_property( + t!("Direction Y").as_ref(), + PROP_EXTRUSION_DIRECTION_Y, + 1, + ), + surface_direction_property( + t!("Direction Z").as_ref(), + PROP_EXTRUSION_DIRECTION_Z, + 2, + ), + Property { + label: t!("Wireframe type").into_owned(), + field: PROP_SURFACE_WIREFRAME_TYPE, + value: PropValue::Choice { + selected: if state.isolines { "Isolines" } else { "Isoparms" } + .to_string(), + options: vec!["Isolines".to_string(), "Isoparms".to_string()], + }, + }, + Property { + label: t!("U isolines").into_owned(), + field: PROP_SURFACE_U_ISOLINES, + value: PropValue::EditText(surface.u_isolines.max(0).to_string()), + }, + Property { + label: t!("V isolines").into_owned(), + field: PROP_SURFACE_V_ISOLINES, + value: PropValue::EditText(surface.v_isolines.max(0).to_string()), + }, + ], + }, + PropSection { + title: t!("Surface Associativity").into_owned(), + props: vec![ + Property { + label: t!("Maintain associativity").into_owned(), + field: PROP_SURFACE_MAINTAIN_ASSOCIATIVITY, + value: yes_no_choice(state.maintain_associativity), + }, + Property { + label: t!("Show associativity").into_owned(), + field: PROP_SURFACE_SHOW_ASSOCIATIVITY, + value: yes_no_choice(state.show_associativity), + }, + ], + }, + PropSection { + title: t!("Trims").into_owned(), + props: vec![ + Property { + label: t!("Trimmed surface").into_owned(), + field: PROP_SURFACE_TRIMMED, + value: PropValue::ReadOnly("No".to_string()), + }, + Property { + label: t!("Trimming edges").into_owned(), + field: PROP_SURFACE_TRIMMING_EDGES, + value: PropValue::ReadOnly("0".to_string()), + }, + ], + }, + ]; + } + let (record_history, object_show_history, show_history_mode) = + history_flags(document, handle).unwrap_or((false, false, 1)); + let (show_history, show_history_editable) = + displayed_history_state(object_show_history, show_history_mode); vec![ PropSection { title: t!("Geometry").into_owned(), @@ -1531,6 +1685,15 @@ pub fn is_history_choice(field: &str) -> bool { matches!(field, PROP_HISTORY | PROP_SHOW_HISTORY) } +pub fn is_surface_property_choice(field: &str) -> bool { + matches!( + field, + PROP_SURFACE_WIREFRAME_TYPE + | PROP_SURFACE_MAINTAIN_ASSOCIATIVITY + | PROP_SURFACE_SHOW_ASSOCIATIVITY + ) +} + pub fn is_loft_geometry_choice(field: &str) -> bool { matches!(field, PROP_LOFT_NORMALS | PROP_LOFT_CLOSED | PROP_LOFT_PERIODIC) } @@ -1538,7 +1701,11 @@ pub fn is_loft_geometry_choice(field: &str) -> bool { pub fn is_specialized_property(field: &str) -> bool { matches!(field, "solid_history_type" | PROP_BANK | PROP_SWEEP_LENGTH | PROP_LOFT_TYPE | PROP_LOFT_SECTION_COUNT - | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z) + | PROP_EXTRUSION_DIRECTION_X | PROP_EXTRUSION_DIRECTION_Y | PROP_EXTRUSION_DIRECTION_Z + | PROP_SURFACE_TYPE | PROP_SURFACE_WIREFRAME_TYPE + | PROP_SURFACE_U_ISOLINES | PROP_SURFACE_V_ISOLINES + | PROP_SURFACE_MAINTAIN_ASSOCIATIVITY | PROP_SURFACE_SHOW_ASSOCIATIVITY + | PROP_SURFACE_TRIMMED | PROP_SURFACE_TRIMMING_EDGES) || is_primitive_property(field) || is_history_choice(field) } @@ -3018,11 +3185,6 @@ fn extrusion_draft_grip( if !inverse.is_finite() { return None; } - let profile = value.sweep_entity.as_ref().and_then(embedded_entity)?; - let normal = crate::entities::curve::entity_curve(&profile)?.plane.normal()?; - let normal = transform - .transform_vector3(glam::DVec3::from_array(normal)) - .try_normalize()?; let center_local = inverse.transform_point3(center); let anchor = profile_grips .into_iter() @@ -3057,7 +3219,7 @@ fn extrusion_draft_grip( value.direction.y, value.direction.z, )); - let height = direction.dot(normal).abs(); + let height = direction.length(); if !direction.is_finite() || !height.is_finite() || height <= 1e-6 { return None; } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index fadc09389..948f1e652 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -22,7 +22,7 @@ fn display_tessellation( body: &Body, facet_resolution: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, ) -> brep::mesh::BodyMesh { let resolution = if facet_resolution.is_finite() && facet_resolution > 0.0 { @@ -35,7 +35,7 @@ fn display_tessellation( |_| cadkernel::tessellation::display_angle_for_resolution(resolution), ); let mut tolerance = brep::mesh::TessellationTolerance::new(max_angle, TOL) - .with_isolines(isolines) + .with_uv_isolines(isolines[0], isolines[1]) .with_planar_isolines(planar_isolines); if let Some(deflection) = chordal_deflection { tolerance = tolerance.with_chordal_deflection(deflection); @@ -278,7 +278,8 @@ pub fn edge_wires(body: &Body) -> Vec { pub fn grip_preview_wires( body: &Body, handle: acadrust::Handle, - isolines: usize, + isolines: [usize; 2], + planar_isolines: bool, ) -> Vec { let tessellation = brep::mesh::tessellate( body, @@ -286,8 +287,8 @@ pub fn grip_preview_wires( cadkernel::tessellation::DEFAULT_ANGLE, TOL, ) - .with_isolines(isolines) - .with_planar_isolines(isolines > 0), + .with_uv_isolines(isolines[0], isolines[1]) + .with_planar_isolines(planar_isolines), ); tessellation .edges @@ -476,7 +477,7 @@ pub fn display_from_solid( color: [f32; 4], facet_resolution: f64, chordal_deflection: Option, - isolines: usize, + isolines: [usize; 2], planar_isolines: bool, ) -> Option<(MeshLodSet, Vec, [f64; 3])> { use acadrust::types::Vector3; @@ -566,7 +567,7 @@ mod tests { use super::*; fn tri_count(body: &Body) -> usize { - display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, 0, false) + display_from_solid(body, [0.7, 0.7, 0.7, 1.0], 1.0, None, [0; 2], false) .map(|(m, _, _)| m.lods[0].indices.len() / 3) .unwrap_or(0) } diff --git a/src/scene/modify.rs b/src/scene/modify.rs index edd7195d5..a3c9434bd 100644 --- a/src/scene/modify.rs +++ b/src/scene/modify.rs @@ -1041,16 +1041,20 @@ impl Scene { { return false; } + let (isoline_counts, planar_isolines) = match self.document.get_entity(handle) { + Some(EntityType::Surface(surface)) => ( + crate::entities::solid3d::surface_isoline_counts(surface), + crate::entities::solid3d::surface_property_state(surface).isolines, + ), + _ => ([0; 2], false), + }; self.solid_history_preview_wires.insert( handle, crate::scene::model::solid_model::grip_preview_wires( &body, handle, - if matches!(self.document.get_entity(handle), Some(EntityType::Surface(_))) { - self.document.header.isolines.max(0) as usize - } else { - 0 - }, + isoline_counts, + planar_isolines, ), ); true diff --git a/src/snap.rs b/src/snap.rs index b3021cc22..8969eff91 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1023,12 +1023,25 @@ impl Snapper { f32::MAX } }; + let flat_ortho = view_rot.z_axis.x.abs() < 1e-9 + && view_rot.z_axis.y.abs() < 1e-9 + && (view_rot.w_axis.w - 1.0).abs() < 1e-6; // Returns false when the wire's AABB does not overlap the snap circle — // safe to skip all vertex work for this wire. // UNBOUNDED_AABB (±infinity) passes through automatically without a // special-case branch because the arithmetic is exact for infinities. let wire_in_range = |wire: &WireModel| -> bool { + // In a tilted or perspective view `cursor_world` lies on the active + // construction plane, while the visible vertex may be anywhere on + // the same view ray. A world-XY AABB comparison can therefore reject + // a 3-D solid corner that is directly under the cursor. The scene's + // screen-space interaction index already performs the broad phase + // for those views; small unindexed drawings are cheap enough to let + // the exact screen-aperture checks below decide. + if !flat_ortho { + return true; + } // The AABB is stored in f32, so at UTM-scale coordinates each bound // is quantized by up to ~1 ulp (≈ coord × 2⁻²³ ≈ 0.7 m at 5.7e6). // When zoomed in hard the snap radius shrinks below that, so the From d4b23112b18999d58234e121502f528184527c39 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:14:05 +0300 Subject: [PATCH 38/53] fix: constrain Draw popup with supported scroll sizing --- src/ui/ribbon/draw_panel.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 7bcf18c9b..093d8fb8d 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -68,20 +68,25 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element let left = anchor.map_or(x, |b| b.x).clamp(0.0, (win.0 - width).max(0.0)); let top = anchor_top.min((win.1 - 80.0).max(0.0)); let available_height = (win.1 - top - 4.0).max(1.0); - let contents: Element<'static, Message> = if let Some(tool) = TOOLS.iter().find(|tool| tool.command == id && !tool.options.is_empty()) { + let cols = (((width - 12.0 + GAP) / (CELL + GAP)).floor() as usize).clamp(1, 7); + let option_tool = TOOLS.iter().find(|tool| tool.command == id && !tool.options.is_empty()); + let content_height = option_tool.map_or_else( + || TOOLS.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, + |tool| tool.options.len() as f32 * 30.0, + ); + let contents: Element<'static, Message> = if let Some(tool) = option_tool { column(tool.options.iter().map(|&(cmd, label)| { button(text(t!(label)).size(11)) .on_press(Message::DropdownSelectItem { dropdown_id: tool.command, cmd }) - .style(popup_row_style).width(Fill).padding([6, 10]).into() + .style(popup_row_style).width(Fill).height(30).padding([6, 10]).into() }).collect::>>()).into() } else { - let cols = (((width - 12.0 + GAP) / (CELL + GAP)).floor() as usize).clamp(1, 7); column(TOOLS.chunks(cols).map(|tools| { row(tools.iter().map(|tool| tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command))) .collect::>()).spacing(GAP).into() }).collect::>>()).spacing(GAP).padding(6).into() }; - let panel = container(scrollable(contents).height(Length::Shrink)) - .max_height(available_height).width(width).style(popup_panel_style); + let panel = container(scrollable(contents).height(content_height.min(available_height))) + .width(width).style(popup_panel_style); dropdown_backdrop(position_ribbon_dropdown(panel.into(), false, left, top)) } From 74d2f428ac1d2a8e297e7c078bba63a4144ba0ff Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 16:14:37 +0300 Subject: [PATCH 39/53] chore: remove unused popup length import --- src/ui/ribbon/draw_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 093d8fb8d..4e8e78614 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -3,7 +3,7 @@ use std::time::Duration; use iced::widget::{button, column, container, row, scrollable, text, tooltip}; -use iced::{Element, Fill, Length, Theme}; +use iced::{Element, Fill, Theme}; use super::widgets::{make_icon, make_tip, muted_text_style, popup_panel_style, popup_row_style, tip_style, tool_btn_style}; use super::{dropdown_backdrop, position_ribbon_dropdown, Ribbon}; From 035d31e2011cf9592bb71cf1f1069e905c46a7a4 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 17:00:16 +0300 Subject: [PATCH 40/53] fix(section): preserve selected slice depth --- src/app/update/command.rs | 37 +++++++++++++++++++------------------ src/entities/extended.rs | 38 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 18 deletions(-) diff --git a/src/app/update/command.rs b/src/app/update/command.rs index c91e0f3e6..43074fa72 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -1443,24 +1443,25 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } // One-shot action — apply immediately. - let unchanged = self.tabs[i] - .scene - .document - .get_entity(popup.handle) - .is_some_and(|entity| match (item.action, entity) { - (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { - !spline.cv_frame_visible - && crate::entities::spline::uses_fit_method(spline) - } - ( - GripMenuAction::ShowControlVertices, - acadrust::EntityType::Spline(spline), - ) => { - spline.cv_frame_visible - || !crate::entities::spline::uses_fit_method(spline) - } - _ => false, - }); + let unchanged = item.label.starts_with('✓') + || self.tabs[i] + .scene + .document + .get_entity(popup.handle) + .is_some_and(|entity| match (item.action, entity) { + (GripMenuAction::ShowFit, acadrust::EntityType::Spline(spline)) => { + !spline.cv_frame_visible + && crate::entities::spline::uses_fit_method(spline) + } + ( + GripMenuAction::ShowControlVertices, + acadrust::EntityType::Spline(spline), + ) => { + spline.cv_frame_visible + || !crate::entities::spline::uses_fit_method(spline) + } + _ => false, + }); if unchanged { return Task::none(); } diff --git a/src/entities/extended.rs b/src/entities/extended.rs index f82a5e261..3712029a3 100644 --- a/src/entities/extended.rs +++ b/src/entities/extended.rs @@ -2179,6 +2179,12 @@ impl Grippable for ExtendedEntity { GripMenuAction::SectionVolume => "Volume", _ => return, }; + let ExtendedEntityData::SectionObject(data) = &self.data else { + return; + }; + if section_kind(self, data) == value { + return; + } apply_section_prop(self, "ext_section_state", value); } } @@ -2198,3 +2204,35 @@ impl Transformable for ExtendedEntity { apply_transform(self, transform); } } + +#[cfg(test)] +mod tests { + use super::*; + use acadrust::entities::EntityCommon; + use acadrust::types::Color; + + #[test] + fn reselecting_slice_keeps_its_depth() { + let mut entity = ExtendedEntity { + common: EntityCommon::new(), + data: ExtendedEntityData::SectionObject(SectionObjectData { + state: 1, + flags: 5, + name: String::new(), + vertical_direction: Vector3::UNIT_Z, + top_height: 1.0, + bottom_height: 1.0, + indicator_alpha: 70, + indicator_color: Color::from_index(9), + back_line_vertices: Vec::new(), + vertices: vec![Vector3::ZERO, Vector3::new(60.0, 0.0, 0.0)], + settings_handle: Handle::NULL, + }), + }; + set_section_slice_metadata(&mut entity, true, 0.25); + + entity.apply_grip_menu(SECTION_GRIP_STATE, GripMenuAction::SectionSlice); + + assert_eq!(section_slice_depth(&entity), Some(0.25)); + } +} From 2f2d894eee806e258a0f9eb855fbb3486a9254be Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 17:14:44 +0300 Subject: [PATCH 41/53] fix(surface): refresh construction display --- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/ocs_doc_api/Cargo.toml | 2 +- src/app/update/command.rs | 7 +++++++ src/scene/model/solid_model.rs | 17 +++++++++++++++++ 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21c353fd5..9acbdfdf0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/ramox81/cadkernel.git?rev=d7431a6ab8600454ab3ad78ad7674576cbc2d5db#d7431a6ab8600454ab3ad78ad7674576cbc2d5db" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=0507b720fef47bd7da0d4f777f97f1743a4f4038#0507b720fef47bd7da0d4f777f97f1743a4f4038" dependencies = [ "acadrust", "cavalier_contours", diff --git a/Cargo.toml b/Cargo.toml index 325321206..0a526489a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"] } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "d7431a6ab8600454ab3ad78ad7674576cbc2d5db", features = ["acis", "offset"] } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"] } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_doc_api/Cargo.toml b/crates/ocs_doc_api/Cargo.toml index 8832710ab..744d3559e 100644 --- a/crates/ocs_doc_api/Cargo.toml +++ b/crates/ocs_doc_api/Cargo.toml @@ -15,7 +15,7 @@ thiserror = "1" # Host adapters convert ObjectId to acadrust handles; kernel handles stay local. acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "f23ffc873228e5f41b876485c741e4a8ac20a67d", features = ["serde"], optional = true } -cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "d7431a6ab8600454ab3ad78ad7674576cbc2d5db", features = ["acis", "offset"], optional = true } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "0507b720fef47bd7da0d4f777f97f1743a4f4038", features = ["acis", "offset"], optional = true } ocs_plugin_api = { path = "../ocs_plugin_api", optional = true, features = ["host"] } [build-dependencies] diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 5c748dbb7..a5b76642b 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -2043,6 +2043,9 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { crate::entities::solid3d::SURFACE_PROPERTIES_APP, Some(crate::entities::solid3d::surface_property_xdata_values(state)), ); + if field == PROP_SURFACE_WIREFRAME_TYPE { + self.tabs[i].scene.reseed_derived_caches(handle); + } } } else if crate::scene::model::solid_history::is_loft_geometry_choice(field) { // These choices change the generated body, not just history @@ -3022,6 +3025,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } + if matches!(field, "srf_u_isolines" | "srf_v_isolines") + { + self.tabs[i].scene.reseed_derived_caches(handle); + } } } } diff --git a/src/scene/model/solid_model.rs b/src/scene/model/solid_model.rs index 948f1e652..1fa867915 100644 --- a/src/scene/model/solid_model.rs +++ b/src/scene/model/solid_model.rs @@ -584,6 +584,23 @@ mod tests { assert!(tri_count(&pyramid_solid(c, 5.0, 9.0, 6).unwrap()) >= 8, "pyramid"); } + #[test] + fn planar_display_uses_independent_isoline_counts() { + let body = box_solid([0.0; 3], 10.0, 10.0, 10.0).unwrap(); + let display = |counts, planar| { + display_from_solid(&body, [0.7, 0.7, 0.7, 1.0], 1.0, None, counts, planar) + .unwrap() + .0 + .edge_verts + .len() + }; + let boundaries = display([0, 0], false); + + assert_eq!(display([1, 0], true) - boundaries, 12); + assert_eq!(display([0, 2], true) - boundaries, 24); + assert_eq!(display([2, 2], false), boundaries); + } + #[test] fn every_primitive_is_the_size_it_was_asked_for() { // Triangle counts say a mesh exists; the volume says it is the right From acba9f87bd08b3eced4e24884ed8137dfb20f9b7 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 17:23:08 +0300 Subject: [PATCH 42/53] refactor(ribbon): simplify draw tool registry --- build.rs | 25 --- src/modules/draw/draw/ray.rs | 33 +++- src/modules/draw/draw/spline.rs | 73 ++++++-- src/ui/ribbon/draw_panel.rs | 206 +++++++++++++++++++--- src/ui/ribbon/draw_tools/01_spline_fit.rs | 6 - src/ui/ribbon/draw_tools/02_spline_cv.rs | 6 - src/ui/ribbon/draw_tools/03_xline.rs | 6 - src/ui/ribbon/draw_tools/04_ray.rs | 6 - src/ui/ribbon/draw_tools/05_multipoint.rs | 6 - src/ui/ribbon/draw_tools/06_divide.rs | 6 - src/ui/ribbon/draw_tools/07_measure.rs | 6 - src/ui/ribbon/draw_tools/08_region.rs | 6 - src/ui/ribbon/draw_tools/09_wipeout.rs | 6 - src/ui/ribbon/draw_tools/10_boundary.rs | 6 - src/ui/ribbon/draw_tools/11_helix.rs | 6 - src/ui/ribbon/draw_tools/12_donut.rs | 6 - src/ui/ribbon/draw_tools/13_revcloud.rs | 10 -- 17 files changed, 269 insertions(+), 150 deletions(-) delete mode 100644 src/ui/ribbon/draw_tools/01_spline_fit.rs delete mode 100644 src/ui/ribbon/draw_tools/02_spline_cv.rs delete mode 100644 src/ui/ribbon/draw_tools/03_xline.rs delete mode 100644 src/ui/ribbon/draw_tools/04_ray.rs delete mode 100644 src/ui/ribbon/draw_tools/05_multipoint.rs delete mode 100644 src/ui/ribbon/draw_tools/06_divide.rs delete mode 100644 src/ui/ribbon/draw_tools/07_measure.rs delete mode 100644 src/ui/ribbon/draw_tools/08_region.rs delete mode 100644 src/ui/ribbon/draw_tools/09_wipeout.rs delete mode 100644 src/ui/ribbon/draw_tools/10_boundary.rs delete mode 100644 src/ui/ribbon/draw_tools/11_helix.rs delete mode 100644 src/ui/ribbon/draw_tools/12_donut.rs delete mode 100644 src/ui/ribbon/draw_tools/13_revcloud.rs diff --git a/build.rs b/build.rs index 97135da56..fd50c581c 100644 --- a/build.rs +++ b/build.rs @@ -4,7 +4,6 @@ use std::path::Path; fn main() { - generate_draw_panel_tools(); let version = std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"); let parts: Vec<&str> = version.split('.').collect(); let app_version = if parts.len() == 3 && parts[0].len() == 4 @@ -82,27 +81,3 @@ fn main() { } } } - -/// Each drawing button can contribute its own expression without editing a registry. -fn generate_draw_panel_tools() { - let manifest = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").expect("Cargo manifest directory")); - let directory = manifest.join("src/ui/ribbon/draw_tools"); - println!("cargo:rerun-if-changed={}", directory.display()); - let mut files = match std::fs::read_dir(&directory) { - Ok(entries) => entries.map(|entry| entry.expect("Draw tool directory entry").path()) - .filter(|path| path.is_file() && path.extension().is_some_and(|extension| extension == "rs")) - .collect::>(), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(), - Err(error) => panic!("Cannot read Draw tool contributions: {error}"), - }; - files.sort_by(|left, right| left.file_name().cmp(&right.file_name())); - let mut source = String::from("const TOOLS: &[Tool] = &[\n"); - for path in files { - println!("cargo:rerun-if-changed={}", path.display()); - // Debug string formatting produces a quoted Rust literal, including Windows escapes. - source.push_str(&format!(" include!({:?}),\n", path.to_str().expect("UTF-8 Draw tool path"))); - } - source.push_str("];\n"); - let output = std::path::PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo output directory")); - std::fs::write(output.join("draw_panel_tools.rs"), source).expect("Write Draw tool registry"); -} diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 30d7b3f6b..6318fde7f 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -4,10 +4,10 @@ // Produces a Ray entity; repeats until Enter/Esc. // XLINE — infinite construction line: same two-click pattern, yields XLine. +use crate::t; use acadrust::entities::{Ray as RayEnt, XLine as XLineEnt}; use acadrust::types::Vector3; use acadrust::EntityType; -use crate::t; use crate::command::{CadCommand, CmdResult}; use crate::scene::model::wire_model::WireModel; @@ -226,7 +226,34 @@ impl CadCommand for XLineCommand { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn repeated_rays_and_xlines_keep_their_first_point() { + let base = DVec3::new(2.0, 3.0, 4.0); + let mut ray = RayCommand::new(); + let mut xline = XLineCommand::new(); + assert!(matches!(ray.on_point(base), CmdResult::NeedPoint)); + assert!(matches!(xline.on_point(base), CmdResult::NeedPoint)); + + for through in [DVec3::new(3.0, 3.0, 4.0), DVec3::new(2.0, 5.0, 4.0)] { + let CmdResult::CommitEntity(EntityType::Ray(entity)) = ray.on_point(through) else { + panic!("ray was not committed"); + }; + assert_eq!(entity.base_point, Vector3::new(base.x, base.y, base.z)); + + let CmdResult::CommitEntity(EntityType::XLine(entity)) = xline.on_point(through) else { + panic!("construction line was not committed"); + }; + assert_eq!(entity.base_point, Vector3::new(base.x, base.y, base.z)); + } + } +} // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["RAY"] }); // RayCommand -inventory::submit!(crate::command::CommandRegistration { names: &["CONSTRUCTIONLINE", "XLINE"] }); // XLineCommand +inventory::submit!(crate::command::CommandRegistration { names: &["RAY"] }); // RayCommand +inventory::submit!(crate::command::CommandRegistration { + names: &["CONSTRUCTIONLINE", "XLINE"] +}); // XLineCommand diff --git a/src/modules/draw/draw/spline.rs b/src/modules/draw/draw/spline.rs index f143b638b..8ace3cb2d 100644 --- a/src/modules/draw/draw/spline.rs +++ b/src/modules/draw/draw/spline.rs @@ -3,9 +3,9 @@ // Command: SPLINE (SPL) // Click to add fit points. Enter (≥2 pts) → commits EntityType::Spline. +use crate::t; use acadrust::types::Vector3; use acadrust::{EntityType, Spline}; -use crate::t; use crate::command::{CadCommand, CmdResult}; use crate::modules::{IconKind, ModuleEvent, ToolDef}; @@ -30,27 +30,35 @@ pub struct SplineCommand { impl SplineCommand { pub fn new() -> Self { - Self { pts: Vec::new(), control_vertices: false, choosing_method: false } + Self { + pts: Vec::new(), + control_vertices: false, + choosing_method: false, + } } pub fn control_vertices() -> Self { - Self { control_vertices: true, ..Self::new() } + Self { + control_vertices: true, + ..Self::new() + } } fn build(&self, closed: bool) -> Option { if self.pts.len() < 2 { return None; } - Some(EntityType::Spline(make_spline(&self.pts, closed, self.control_vertices))) + Some(EntityType::Spline(make_spline( + &self.pts, + closed, + self.control_vertices, + ))) } } /// Store the chosen construction method directly in the persistent spline. fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { - let mut points: Vec = pts - .iter() - .map(|p| Vector3::new(p.x, p.y, p.z)) - .collect(); + let mut points: Vec = pts.iter().map(|p| Vector3::new(p.x, p.y, p.z)).collect(); let mut spline = if control_vertices { if closed && pts.first() != pts.last() { points.push(points[0]); @@ -61,9 +69,13 @@ fn make_spline(pts: &[DVec3], closed: bool, control_vertices: bool) -> Spline { // Open uniform knot vector: degree + 1 equal knots at each endpoint. let knots = (0..count + degree + 1) .map(|index| { - if index <= degree { 0.0 } - else if index >= count { 1.0 } - else { (index - degree) as f64 / spans as f64 } + if index <= degree { + 0.0 + } else if index >= count { + 1.0 + } else { + (index - degree) as f64 / spans as f64 + } }) .collect(); Spline { @@ -103,7 +115,11 @@ fn sample_curve(pts: &[DVec3], closed: bool, control_vertices: bool) -> Vec<[f32 impl CadCommand for SplineCommand { fn name(&self) -> &'static str { - if self.control_vertices { "SPLINECV" } else { "SPLINE" } + if self.control_vertices { + "SPLINECV" + } else { + "SPLINE" + } } fn prompt(&self) -> String { @@ -122,7 +138,10 @@ impl CadCommand for SplineCommand { fn options(&self) -> Vec { use crate::command::CmdOption; if self.choosing_method { - return vec![CmdOption::new("Fit", "FIT"), CmdOption::new("Control vertices", "CV")]; + return vec![ + CmdOption::new("Fit", "FIT"), + CmdOption::new("Control vertices", "CV"), + ]; } if self.pts.is_empty() { return vec![CmdOption::new(t!("Method").as_ref(), "M")]; @@ -213,6 +232,32 @@ impl CadCommand for SplineCommand { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn control_vertices_make_a_clamped_finite_spline() { + let points = [ + DVec3::new(0.0, 0.0, 0.0), + DVec3::new(1.0, 2.0, 0.0), + DVec3::new(2.0, 2.0, 0.0), + DVec3::new(3.0, 0.0, 0.0), + ]; + let spline = make_spline(&points, false, true); + + assert_eq!(spline.degree, 3); + assert_eq!(spline.control_points.len(), 4); + assert_eq!(spline.knots, vec![0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0, 1.0]); + assert_eq!(spline.weights, vec![1.0; 4]); + assert!(sample_curve(&points, false, true) + .iter() + .flatten() + .all(|value| value.is_finite())); + } +} // ── Autocomplete registry ───────────────────────────────── -inventory::submit!(crate::command::CommandRegistration { names: &["SPLINE", "SPLINECV"] }); // SplineCommand +inventory::submit!(crate::command::CommandRegistration { + names: &["SPLINE", "SPLINECV"] +}); // SplineCommand diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 4e8e78614..2f8267d70 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -5,7 +5,10 @@ use std::time::Duration; use iced::widget::{button, column, container, row, scrollable, text, tooltip}; use iced::{Element, Fill, Theme}; -use super::widgets::{make_icon, make_tip, muted_text_style, popup_panel_style, popup_row_style, tip_style, tool_btn_style}; +use super::widgets::{ + make_icon, make_tip, muted_text_style, popup_panel_style, popup_row_style, tip_style, + tool_btn_style, +}; use super::{dropdown_backdrop, position_ribbon_dropdown, Ribbon}; use crate::app::Message; use crate::modules::IconKind; @@ -23,39 +26,158 @@ struct Tool { options: &'static [(&'static str, &'static str)], } -include!(concat!(env!("OUT_DIR"), "/draw_panel_tools.rs")); +const TOOLS: &[Tool] = &[ + Tool { + command: "SPLINE", + label: "Spline Fit", + icon: include_bytes!("../../../assets/icons/spline.svg"), + options: &[], + }, + Tool { + command: "SPLINECV", + label: "Spline CV", + icon: include_bytes!("../../../assets/icons/spline_cv.svg"), + options: &[], + }, + Tool { + command: "XLINE", + label: "Construction Line", + icon: include_bytes!("../../../assets/icons/xline.svg"), + options: &[], + }, + Tool { + command: "RAY", + label: "Ray", + icon: include_bytes!("../../../assets/icons/ray.svg"), + options: &[], + }, + Tool { + command: "MULTIPOINT", + label: "Multiple Points", + icon: include_bytes!("../../../assets/icons/multipoint.svg"), + options: &[ + ("POINT", "Single Point"), + ("MULTIPOINT", "Multiple Points"), + ("DDPTYPE", "Point Style"), + ], + }, + Tool { + command: "DIVIDE", + label: "Divide", + icon: include_bytes!("../../../assets/icons/divide.svg"), + options: &[], + }, + Tool { + command: "MEASURE", + label: "Measure", + icon: include_bytes!("../../../assets/icons/measure.svg"), + options: &[], + }, + Tool { + command: "REGION", + label: "Region", + icon: include_bytes!("../../../assets/icons/region.svg"), + options: &[], + }, + Tool { + command: "WIPEOUT", + label: "Wipeout", + icon: include_bytes!("../../../assets/icons/wipeout.svg"), + options: &[], + }, + Tool { + command: "BOUNDARY", + label: "Boundary", + icon: include_bytes!("../../../assets/icons/boundary.svg"), + options: &[], + }, + Tool { + command: "HELIX", + label: "Helix", + icon: include_bytes!("../../../assets/icons/helix.svg"), + options: &[], + }, + Tool { + command: "DONUT", + label: "Donut", + icon: include_bytes!("../../../assets/icons/donut.svg"), + options: &[], + }, + Tool { + command: "REVCLOUD", + label: "Revision Cloud", + icon: include_bytes!("../../../assets/icons/revcloud.svg"), + options: &[ + ("REVCLOUD_RECTANGULAR", "Rectangular"), + ("REVCLOUD_POLYGONAL", "Polygonal"), + ("REVCLOUD_FREEHAND", "Freehand"), + ], + }, +]; pub(super) fn owns_dropdown(id: &str) -> bool { - id == PANEL_ID || TOOLS.iter().any(|tool| tool.command == id && !tool.options.is_empty()) + id == PANEL_ID + || TOOLS + .iter() + .any(|tool| tool.command == id && !tool.options.is_empty()) } pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { if title != "Draw" || TOOLS.is_empty() { - return container(text(t!(title)).size(9).style(muted_text_style)).padding([1, 4]).into(); + return container(text(t!(title)).size(9).style(muted_text_style)) + .padding([1, 4]) + .into(); } let expanded = open.as_deref().is_some_and(owns_dropdown); - let arrow = if expanded { icons::themed_arrow_up(7.0) } else { icons::themed_arrow_down(7.0) }; - PosReport::new(PANEL_ID, button(row![text(t!(title)).size(9), arrow].spacing(4).align_y(iced::Center)) + let arrow = if expanded { + icons::themed_arrow_up(7.0) + } else { + icons::themed_arrow_down(7.0) + }; + PosReport::new( + PANEL_ID, + button( + row![text(t!(title)).size(9), arrow] + .spacing(4) + .align_y(iced::Center), + ) .on_press(Message::ToggleRibbonDropdown(PANEL_ID.to_string())) .style(move |theme: &Theme, status| tool_btn_style(theme, expanded, status)) - .padding([1, 4])).into() + .padding([1, 4]), + ) + .into() } fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { let face = button(make_icon(IconKind::Svg(tool.icon), 23.0)) - .on_press(Message::DropdownSelectItem { dropdown_id: PANEL_ID, cmd: tool.command }) + .on_press(Message::DropdownSelectItem { + dropdown_id: PANEL_ID, + cmd: tool.command, + }) .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) - .width(if tool.options.is_empty() { CELL } else { CELL - 11.0 }) + .width(if tool.options.is_empty() { + CELL + } else { + CELL - 11.0 + }) .height(CELL) .padding(3); let tip = format!("{}\n{} {}", t!(tool.label), t!("Command:"), tool.command); let face: Element<'static, Message> = tooltip(face, make_tip(tip), tooltip::Position::Bottom) - .delay(Duration::from_millis(400)).style(tip_style).into(); + .delay(Duration::from_millis(400)) + .style(tip_style) + .into(); if !tool.options.is_empty() { - row![face, button(icons::themed_arrow_down(6.0)) - .on_press(Message::ToggleRibbonDropdown(tool.command.to_string())) - .style(move |theme: &Theme, status| tool_btn_style(theme, false, status)) - .width(11).height(CELL).padding(2)].into() + row![ + face, + button(icons::themed_arrow_down(6.0)) + .on_press(Message::ToggleRibbonDropdown(tool.command.to_string())) + .style(move |theme: &Theme, status| tool_btn_style(theme, false, status)) + .width(11) + .height(CELL) + .padding(2) + ] + .into() } else { face } @@ -65,28 +187,60 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element let width = (7.0 * (CELL + GAP) - GAP + 12.0).min((win.0 - 8.0).max(CELL + 12.0)); let (_, x, anchor_top) = ribbon.dd_anchor(PANEL_ID, width, win.0); let anchor = crate::ui::wrap_bar::dropdown_bounds(PANEL_ID); - let left = anchor.map_or(x, |b| b.x).clamp(0.0, (win.0 - width).max(0.0)); + let left = anchor + .map_or(x, |b| b.x) + .clamp(0.0, (win.0 - width).max(0.0)); let top = anchor_top.min((win.1 - 80.0).max(0.0)); let available_height = (win.1 - top - 4.0).max(1.0); let cols = (((width - 12.0 + GAP) / (CELL + GAP)).floor() as usize).clamp(1, 7); - let option_tool = TOOLS.iter().find(|tool| tool.command == id && !tool.options.is_empty()); + let option_tool = TOOLS + .iter() + .find(|tool| tool.command == id && !tool.options.is_empty()); let content_height = option_tool.map_or_else( || TOOLS.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, |tool| tool.options.len() as f32 * 30.0, ); let contents: Element<'static, Message> = if let Some(tool) = option_tool { - column(tool.options.iter().map(|&(cmd, label)| { - button(text(t!(label)).size(11)) - .on_press(Message::DropdownSelectItem { dropdown_id: tool.command, cmd }) - .style(popup_row_style).width(Fill).height(30).padding([6, 10]).into() - }).collect::>>()).into() + column( + tool.options + .iter() + .map(|&(cmd, label)| { + button(text(t!(label)).size(11)) + .on_press(Message::DropdownSelectItem { + dropdown_id: tool.command, + cmd, + }) + .style(popup_row_style) + .width(Fill) + .height(30) + .padding([6, 10]) + .into() + }) + .collect::>>(), + ) + .into() } else { - column(TOOLS.chunks(cols).map(|tools| { - row(tools.iter().map(|tool| tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command))) - .collect::>()).spacing(GAP).into() - }).collect::>>()).spacing(GAP).padding(6).into() + column( + TOOLS + .chunks(cols) + .map(|tools| { + row(tools + .iter() + .map(|tool| { + tool_button(tool, ribbon.active_tool.as_deref() == Some(tool.command)) + }) + .collect::>()) + .spacing(GAP) + .into() + }) + .collect::>>(), + ) + .spacing(GAP) + .padding(6) + .into() }; let panel = container(scrollable(contents).height(content_height.min(available_height))) - .width(width).style(popup_panel_style); + .width(width) + .style(popup_panel_style); dropdown_backdrop(position_ribbon_dropdown(panel.into(), false, left, top)) } diff --git a/src/ui/ribbon/draw_tools/01_spline_fit.rs b/src/ui/ribbon/draw_tools/01_spline_fit.rs deleted file mode 100644 index 7eae11258..000000000 --- a/src/ui/ribbon/draw_tools/01_spline_fit.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "SPLINE", - label: "Spline Fit", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/spline.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/02_spline_cv.rs b/src/ui/ribbon/draw_tools/02_spline_cv.rs deleted file mode 100644 index ddb30c5e2..000000000 --- a/src/ui/ribbon/draw_tools/02_spline_cv.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "SPLINECV", - label: "Spline CV", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/spline_cv.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/03_xline.rs b/src/ui/ribbon/draw_tools/03_xline.rs deleted file mode 100644 index 65e5138f8..000000000 --- a/src/ui/ribbon/draw_tools/03_xline.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "XLINE", - label: "Construction Line", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/xline.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/04_ray.rs b/src/ui/ribbon/draw_tools/04_ray.rs deleted file mode 100644 index 3fc739731..000000000 --- a/src/ui/ribbon/draw_tools/04_ray.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "RAY", - label: "Ray", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/ray.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/05_multipoint.rs b/src/ui/ribbon/draw_tools/05_multipoint.rs deleted file mode 100644 index c2a3445e6..000000000 --- a/src/ui/ribbon/draw_tools/05_multipoint.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "MULTIPOINT", - label: "Multiple Points", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/multipoint.svg")), - options: &[("POINT", "Single Point"), ("MULTIPOINT", "Multiple Points"), ("DDPTYPE", "Point Style")], -} diff --git a/src/ui/ribbon/draw_tools/06_divide.rs b/src/ui/ribbon/draw_tools/06_divide.rs deleted file mode 100644 index 9ea3d44a3..000000000 --- a/src/ui/ribbon/draw_tools/06_divide.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "DIVIDE", - label: "Divide", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/divide.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/07_measure.rs b/src/ui/ribbon/draw_tools/07_measure.rs deleted file mode 100644 index 359b1afc8..000000000 --- a/src/ui/ribbon/draw_tools/07_measure.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "MEASURE", - label: "Measure", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/measure.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/08_region.rs b/src/ui/ribbon/draw_tools/08_region.rs deleted file mode 100644 index a09ec4dea..000000000 --- a/src/ui/ribbon/draw_tools/08_region.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "REGION", - label: "Region", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/region.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/09_wipeout.rs b/src/ui/ribbon/draw_tools/09_wipeout.rs deleted file mode 100644 index ba3210262..000000000 --- a/src/ui/ribbon/draw_tools/09_wipeout.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "WIPEOUT", - label: "Wipeout", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/wipeout.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/10_boundary.rs b/src/ui/ribbon/draw_tools/10_boundary.rs deleted file mode 100644 index 395ecb34f..000000000 --- a/src/ui/ribbon/draw_tools/10_boundary.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "BOUNDARY", - label: "Boundary", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/boundary.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/11_helix.rs b/src/ui/ribbon/draw_tools/11_helix.rs deleted file mode 100644 index 844397765..000000000 --- a/src/ui/ribbon/draw_tools/11_helix.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "HELIX", - label: "Helix", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/helix.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/12_donut.rs b/src/ui/ribbon/draw_tools/12_donut.rs deleted file mode 100644 index 3761f1b61..000000000 --- a/src/ui/ribbon/draw_tools/12_donut.rs +++ /dev/null @@ -1,6 +0,0 @@ -Tool { - command: "DONUT", - label: "Donut", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/donut.svg")), - options: &[], -} diff --git a/src/ui/ribbon/draw_tools/13_revcloud.rs b/src/ui/ribbon/draw_tools/13_revcloud.rs deleted file mode 100644 index cedb4a4f5..000000000 --- a/src/ui/ribbon/draw_tools/13_revcloud.rs +++ /dev/null @@ -1,10 +0,0 @@ -Tool { - command: "REVCLOUD", - label: "Revision Cloud", - icon: include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/icons/revcloud.svg")), - options: &[ - ("REVCLOUD_RECTANGULAR", "Rectangular"), - ("REVCLOUD_POLYGONAL", "Polygonal"), - ("REVCLOUD_FREEHAND", "Freehand"), - ], -} From 4de27927985f9f34b36669cabd7aef4d0df64dc2 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 18:00:07 +0300 Subject: [PATCH 43/53] fix(doc-api): preserve XDATA and XRecord integrity --- crates/ocs_doc_api/build.rs | 5 +- crates/ocs_doc_api/spec/entities.toml | 17 +- crates/ocs_doc_api/src/executor.rs | 15 +- crates/ocs_doc_api/src/facade.rs | 40 ++-- crates/ocs_doc_api/src/gen/api_reference.md | 9 +- .../ocs_doc_api/src/gen/binding_schema.json | 38 +--- crates/ocs_doc_api/src/validation.rs | 117 +++++++++- crates/ocs_doc_api/tests/mock_backend.rs | 33 +++ src/app/doc_api.rs | 201 ++++++++++++++++-- 9 files changed, 367 insertions(+), 108 deletions(-) diff --git a/crates/ocs_doc_api/build.rs b/crates/ocs_doc_api/build.rs index 67fab317a..c951f0d66 100644 --- a/crates/ocs_doc_api/build.rs +++ b/crates/ocs_doc_api/build.rs @@ -172,8 +172,9 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, - **`set_layer(layer)`** — move the entity to an existing layer (one undo step; blocked if the entity or target layer is locked). -`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed -downcast when `kind == "Solid3D"`). +`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed +downcast when `kind == "Solid3D"`), and XDATA access through +`xdata(application_name)` / `set_xdata(application_name, record)`. ## Layer table diff --git a/crates/ocs_doc_api/spec/entities.toml b/crates/ocs_doc_api/spec/entities.toml index e490f7d7b..4de4cf6eb 100644 --- a/crates/ocs_doc_api/spec/entities.toml +++ b/crates/ocs_doc_api/spec/entities.toml @@ -471,7 +471,7 @@ description = "Extended record (XRecord): arbitrary DXF group-code/value pairs. name = "create_xrecord" args = [{ name = "spec", ty = "XRecordSpec" }] op = "CreateXRecord" -returns = "Entity" +returns = "XRecord" [[family.method]] name = "payload" @@ -486,21 +486,6 @@ op = "SetXRecord" args = [{ name = "spec", ty = "XRecordSpec" }] returns = "()" -# Cross-cutting: every Entity handle can read/write its own XDATA. -[[family.method]] -name = "xdata" -kind = "query" -query = "GetXData" -args = [{ name = "application_name", ty = "&str" }] -returns = "Option" - -[[family.method]] -name = "set_xdata" -kind = "op" -op = "SetXData" -args = [{ name = "application_name", ty = "&str" }, { name = "record", ty = "Option" }] -returns = "()" - # ============================================================================ # Cross-cutting ops (generic handles + bulk) — documented in build.rs # ============================================================================ diff --git a/crates/ocs_doc_api/src/executor.rs b/crates/ocs_doc_api/src/executor.rs index 0d11ad799..423fc0658 100644 --- a/crates/ocs_doc_api/src/executor.rs +++ b/crates/ocs_doc_api/src/executor.rs @@ -411,12 +411,15 @@ fn apply_op_inner(b: &mut B, op: Operation) -> ApiResult { - // Pre-validate every entity id before any mutation. - for (id, app, _) in records { - require_exists(b, *id, name)?; - if app.is_empty() { - return Err(ApiError::validation(name, "empty application_name")); - } + // A later locked entity must not leave earlier records changed. + for (i, (id, _, _)) in records.iter().enumerate() { + b.can_modify(*id).map_err(|e| match e { + ApiError::UnknownId(_) => stale_index(name, i, *id), + ApiError::Unsupported(reason) => { + ApiError::validation(name, format!("index {i}: {reason}")) + } + other => other, + })?; } b.push_undo(name); for (id, application_name, record) in records { diff --git a/crates/ocs_doc_api/src/facade.rs b/crates/ocs_doc_api/src/facade.rs index 104b0a853..ef6f2b13d 100644 --- a/crates/ocs_doc_api/src/facade.rs +++ b/crates/ocs_doc_api/src/facade.rs @@ -386,6 +386,12 @@ macro_rules! handle { self.id } } + }; +} + +macro_rules! entity_handle { + ($name:ident) => { + handle!($name); impl $name { pub fn bounds(&self) -> ApiResult { match self.session.one_query(Query::GetBounds { id: self.id })? { @@ -427,20 +433,20 @@ macro_rules! handle { }; } -handle!(Entity); -handle!(Solid); -handle!(Line); -handle!(Circle); -handle!(Polyline); -handle!(Point); -handle!(ArcCurve); -handle!(Ellipse); -handle!(Spline); -handle!(Ray); -handle!(XLine); -handle!(Text); -handle!(MText); -handle!(Dimension); +entity_handle!(Entity); +entity_handle!(Solid); +entity_handle!(Line); +entity_handle!(Circle); +entity_handle!(Polyline); +entity_handle!(Point); +entity_handle!(ArcCurve); +entity_handle!(Ellipse); +entity_handle!(Spline); +entity_handle!(Ray); +entity_handle!(XLine); +entity_handle!(Text); +entity_handle!(MText); +entity_handle!(Dimension); handle!(XRecord); impl XRecord { @@ -1308,8 +1314,8 @@ impl EntityCollection { Ok(()) } - /// Create a standalone `XRECORD` object (returns an `Entity` handle). - pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult { + /// Create a standalone `XRECORD` object. + pub fn create_xrecord(&self, spec: &XRecordSpec) -> ApiResult { let receipt = self .session .apply_op(Operation::CreateXRecord(spec.clone()))?; @@ -1317,7 +1323,7 @@ impl EntityCollection { .outcome .and_then(|o| o.new_id()) .ok_or_else(|| ApiError::Transport("create_xrecord returned no id".into()))?; - Ok(Entity::new(self.session.clone(), id)) + Ok(XRecord::new(self.session.clone(), id)) } /// Lookup an `XRECORD` object by id and return a typed accessor handle. diff --git a/crates/ocs_doc_api/src/gen/api_reference.md b/crates/ocs_doc_api/src/gen/api_reference.md index 3430545b6..a0396caa1 100644 --- a/crates/ocs_doc_api/src/gen/api_reference.md +++ b/crates/ocs_doc_api/src/gen/api_reference.md @@ -198,14 +198,12 @@ Extended record (XRecord): arbitrary DXF group-code/value pairs. Roundtripped ve ### Constructors (`doc.entities()`) -- **create_xrecord**(spec: XRecordSpec) -> `Entity` — `CreateXRecord` +- **create_xrecord**(spec: XRecordSpec) -> `XRecord` — `CreateXRecord` ### Methods - **payload**() -> `XRecordSpec` — query `GetXRecord` - **set_payload**(spec: XRecordSpec) -> `()` — op `SetXRecord` -- **xdata**(application_name: &str) -> `Option` — query `GetXData` -- **set_xdata**(application_name: &str, record: Option) -> `()` — op `SetXData` ## `Layer` (acadrust `-`, collection `layers`) @@ -234,8 +232,9 @@ Every typed handle (`Solid`, `Line`, `Circle`, `Polyline`, `Point`, `ArcCurve`, - **`set_layer(layer)`** — move the entity to an existing layer (one undo step; blocked if the entity or target layer is locked). -`Entity` additionally has `view()` (id + kind + bounds) and `as_solid()` (typed -downcast when `kind == "Solid3D"`). +`Entity` additionally has `view()` (id + kind + bounds), `as_solid()` (typed +downcast when `kind == "Solid3D"`), and XDATA access through +`xdata(application_name)` / `set_xdata(application_name, record)`. ## Layer table diff --git a/crates/ocs_doc_api/src/gen/binding_schema.json b/crates/ocs_doc_api/src/gen/binding_schema.json index 3a7d454c6..04697d696 100644 --- a/crates/ocs_doc_api/src/gen/binding_schema.json +++ b/crates/ocs_doc_api/src/gen/binding_schema.json @@ -1140,7 +1140,7 @@ "fixed": null, "name": "create_xrecord", "op": "CreateXRecord", - "returns": "Entity" + "returns": "XRecord" } ], "handle": "XRecord", @@ -1171,42 +1171,6 @@ "query": null, "read_only": false, "returns": "()" - }, - { - "args": [ - { - "name": "application_name", - "ty": "&str" - } - ], - "bulk": false, - "fixed": null, - "kind": "query", - "name": "xdata", - "op": null, - "query": "GetXData", - "read_only": true, - "returns": "Option" - }, - { - "args": [ - { - "name": "application_name", - "ty": "&str" - }, - { - "name": "record", - "ty": "Option" - } - ], - "bulk": false, - "fixed": null, - "kind": "op", - "name": "set_xdata", - "op": "SetXData", - "query": null, - "read_only": false, - "returns": "()" } ], "name": "XRecord" diff --git a/crates/ocs_doc_api/src/validation.rs b/crates/ocs_doc_api/src/validation.rs index a6e93067a..dc9ca9909 100644 --- a/crates/ocs_doc_api/src/validation.rs +++ b/crates/ocs_doc_api/src/validation.rs @@ -21,6 +21,88 @@ fn check(valid: bool, op: &'static str) -> ApiResult<()> { } } +fn check_payload(valid: bool, op: &'static str, reason: &'static str) -> ApiResult<()> { + if valid { + Ok(()) + } else { + Err(ApiError::validation(op, reason)) + } +} + +fn valid_application_name(name: &str) -> bool { + !name.trim().is_empty() && !name.contains('\0') +} + +fn valid_xdata_record(app: &str, record: Option<&XDataRecord>) -> bool { + valid_application_name(app) + && record.is_none_or(|record| { + record.application_name == app + && record.values.iter().all(|value| match value { + XDataValue::String(value) + | XDataValue::ControlString(value) + | XDataValue::LayerName(value) => !value.contains('\0'), + XDataValue::Point3D(value) + | XDataValue::Position3D(value) + | XDataValue::Displacement3D(value) + | XDataValue::Direction3D(value) => finite(value), + XDataValue::Real(value) + | XDataValue::Distance(value) + | XDataValue::ScaleFactor(value) => value.is_finite(), + XDataValue::BinaryData(_) + | XDataValue::Handle(_) + | XDataValue::Integer16(_) + | XDataValue::Integer32(_) => true, + }) + }) +} + +fn valid_xrecord(spec: &XRecordSpec) -> bool { + !spec.name.trim().is_empty() + && !spec.name.contains('\0') + && spec.entries.len() <= BULK_ITEM_CAP + && spec.entries.iter().all(|entry| { + let code = entry.code; + match &entry.value { + XRecordValue::String(value) => { + matches!( + code, + 0..=4 + | 6..=9 + | 100..=102 + | 300..=309 + | 410..=419 + | 430..=439 + | 470..=479 + | 999 + | 1000..=1003 + ) && !value.contains('\0') + && value.encode_utf16().count() <= u16::MAX as usize + } + XRecordValue::Point3D(value) => { + matches!(code, 10..=37 | 110..=139 | 210..=269 | 1010..=1039 | 1043..=1069) + && finite(value) + } + XRecordValue::Double(value) => { + matches!(code, 38..=59 | 140..=149 | 460..=469 | 1040..=1042) + && value.is_finite() + } + XRecordValue::Int16(_) => { + matches!(code, 60..=79 | 170..=179 | 270..=279 | 370..=389 | 400..=409 | 1070) + } + XRecordValue::Int32(_) => matches!(code, 80..=99 | 420..=429 | 440..=459 | 1071), + XRecordValue::Int64(_) => matches!(code, 150..=169), + XRecordValue::Byte(_) => matches!(code, 280..=289), + XRecordValue::Bool(_) => matches!(code, 290..=299), + XRecordValue::Handle(_) => { + code < 0 || matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480..=481 | 1005) + } + XRecordValue::Chunk(value) => { + matches!(code, 310..=319 | 1004) && value.len() <= u8::MAX as usize + } + } + }) +} + pub(crate) fn curve(spec: &Curve2Spec) -> ApiResult<()> { use Curve2Spec::*; let valid = match spec { @@ -116,11 +198,36 @@ pub(crate) fn operation(op: &Operation) -> ApiResult<()> { } CreateText(s) => finite(&s.insertion_point) && positive(s.height) && s.rotation.is_finite(), CreateMText(s) => finite(&s.insertion_point) && positive(s.height), - SetXDataMany(records) => records.iter().all(|(id, app, record)| { - let app_ok = !app.is_empty(); - let record_ok = record.as_ref().map_or(true, |r| !r.application_name.is_empty() && !r.application_name.contains('\0')); - *id != ObjectId::NULL && app_ok && record_ok - }), + SetXData { + id, + application_name, + record, + } => { + return check_payload( + *id != ObjectId::NULL && valid_xdata_record(application_name, record.as_ref()), + "SetXData", + "invalid XDATA record", + ) + } + SetXDataMany(records) => { + return check_payload( + records.iter().all(|(id, app, record)| { + *id != ObjectId::NULL && valid_xdata_record(app, record.as_ref()) + }), + "SetXDataMany", + "invalid XDATA record", + ) + } + CreateXRecord(spec) => { + return check_payload(valid_xrecord(spec), "CreateXRecord", "invalid XRecord payload") + } + SetXRecord { id, spec } => { + return check_payload( + *id != ObjectId::NULL && valid_xrecord(spec), + "SetXRecord", + "invalid XRecord payload", + ) + } CreateHatch(s) => { (3..=BULK_ITEM_CAP).contains(&s.boundary.len()) && s.boundary.iter().all(|p| finite(p)) } diff --git a/crates/ocs_doc_api/tests/mock_backend.rs b/crates/ocs_doc_api/tests/mock_backend.rs index 112c93e8d..d9059bbde 100644 --- a/crates/ocs_doc_api/tests/mock_backend.rs +++ b/crates/ocs_doc_api/tests/mock_backend.rs @@ -985,6 +985,39 @@ fn set_xdata_many_with_unknown_id_fails_atomically() { assert!(doc.entities().get(line.id()).unwrap().xdata("APP1").unwrap().is_none()); } +#[test] +fn xdata_rejects_a_mismatched_record_name() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + let line = doc.curves().create_line([0.0; 3], [1.0; 3]).unwrap(); + let entity = doc.entities().get(line.id()).unwrap(); + let result = entity.set_xdata( + "EXPECTED", + Some(XDataRecord { + application_name: "OTHER".into(), + values: vec![], + }), + ); + assert!(matches!(result, Err(ApiError::Validation { .. }))); + assert!(entity.xdata("EXPECTED").unwrap().is_none()); +} + +#[test] +fn create_xrecord_returns_the_typed_handle() { + let (api, _tp) = api(); + let doc = api.document(api.active_tab()); + let spec = ocs_doc_api::XRecordSpec { + name: "PLUGIN_DATA".into(), + cloning_flags: ocs_doc_api::XRecordCloningFlags::NotApplicable, + entries: vec![ocs_doc_api::XRecordEntry { + code: 1, + value: ocs_doc_api::XRecordValue::String("value".into()), + }], + }; + let record = doc.entities().create_xrecord(&spec).unwrap(); + assert_eq!(record.payload().unwrap(), spec); +} + #[test] fn create_many_applies_per_curve_layer() { let (api, _tp) = api(); diff --git a/src/app/doc_api.rs b/src/app/doc_api.rs index fe23287ed..4c27a8fa1 100644 --- a/src/app/doc_api.rs +++ b/src/app/doc_api.rs @@ -48,6 +48,28 @@ fn xrecord_dictionary_handle(doc: &mut acadrust::CadDocument) -> Handle { } } +fn xrecord_owner_dictionary( + doc: &acadrust::CadDocument, + record_handle: Handle, + owner: Handle, +) -> Option { + let owns_record = |handle: Handle| { + matches!(doc.objects.get(&handle), Some(ObjectType::Dictionary(dict)) if + dict.entries.iter().any(|(_, child)| *child == record_handle)) + }; + if owns_record(owner) { + return Some(owner); + } + doc.objects.iter().find_map(|(handle, object)| match object { + ObjectType::Dictionary(dict) + if dict.entries.iter().any(|(_, child)| *child == record_handle) => + { + Some(*handle) + } + _ => None, + }) +} + /// Entry point called by the `HostApi::doc_api_dispatch` override (below) and by // the in-process path. Deserializes the envelope, runs the crate executor, // serializes the `Receipt` (or `ApiError`) back to bytes. @@ -771,7 +793,17 @@ impl DocApiBackend for HostSession<'_> { fn add_xrecord(&mut self, spec: &ocs_doc_api::XRecordSpec) -> ApiResult { use acadrust::objects::{ObjectType, XRecord}; + let dict_h = xrecord_dictionary_handle(self.document_mut()); + if matches!(self.document().objects.get(&dict_h), Some(ObjectType::Dictionary(dict)) if + dict.entries.iter().any(|(name, _)| name.eq_ignore_ascii_case(&spec.name))) + { + return Err(ApiError::validation( + "CreateXRecord", + format!("XRecord '{}' already exists", spec.name), + )); + } let mut record = XRecord::named(spec.name.clone()); + record.owner = dict_h; record.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); record.entries = spec .entries @@ -789,9 +821,7 @@ impl DocApiBackend for HostSession<'_> { }; // Attach the new XRecord to the stable named-object dictionary so it is // reachable as a standalone named object. - let dict_h = xrecord_dictionary_handle(self.document_mut()); if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { - dict.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(&spec.name)); dict.add_entry(spec.name.clone(), record_handle); } Ok(handle_to_obj(record_handle)) @@ -808,9 +838,21 @@ impl DocApiBackend for HostSession<'_> { let Some(ObjectType::XRecord(record)) = self.document().objects.get(&handle) else { return Err(ApiError::Unsupported(format!("ObjectId {id:?} is not an XRecord"))); }; - let old_name = record.name.clone(); let mut updated = record.clone(); + let dict_h = xrecord_owner_dictionary(self.document(), handle, record.owner) + .unwrap_or_else(|| xrecord_dictionary_handle(self.document_mut())); + if matches!(self.document().objects.get(&dict_h), Some(ObjectType::Dictionary(dict)) if + dict.entries.iter().any(|(name, child)| { + *child != handle && name.eq_ignore_ascii_case(&spec.name) + })) + { + return Err(ApiError::validation( + "SetXRecord", + format!("XRecord '{}' already exists", spec.name), + )); + } updated.name = spec.name.clone(); + updated.owner = dict_h; updated.cloning_flags = cloning_flags_to_acadrust(spec.cloning_flags); updated.entries = spec .entries @@ -821,16 +863,9 @@ impl DocApiBackend for HostSession<'_> { self.document_mut() .objects .insert(handle, acadrust::objects::ObjectType::XRecord(updated)); - // If the name changed, rename the dictionary entry so the XRecord remains - // reachable under its new name. - if !old_name.eq_ignore_ascii_case(&spec.name) { - let dict_h = xrecord_dictionary_handle(self.document_mut()); - if let Some(ObjectType::Dictionary(dict)) = - self.document_mut().objects.get_mut(&dict_h) - { - dict.entries.retain(|(name, _)| !name.eq_ignore_ascii_case(&old_name)); - dict.add_entry(spec.name.clone(), handle); - } + if let Some(ObjectType::Dictionary(dict)) = self.document_mut().objects.get_mut(&dict_h) { + dict.entries.retain(|(_, child)| *child != handle); + dict.add_entry(spec.name.clone(), handle); } Ok(()) } @@ -906,7 +941,7 @@ impl DocApiBackend for HostSession<'_> { Ok(()) } fn delete_layer(&mut self, name: &str) -> ApiResult<()> { - let name_upper = acadrust::tables::normalize_name(name); + let name_upper = acadrust::tables::normalize_name(name.trim()); if name_upper == "0" { return Err(ApiError::validation("DeleteLayer", "cannot delete layer 0")); } @@ -1008,7 +1043,9 @@ impl DocApiBackend for HostSession<'_> { } } if let Some(l) = layer { - if !entity.common().layer.eq_ignore_ascii_case(l) { + if acadrust::tables::normalize_name(&entity.common().layer) + != acadrust::tables::normalize_name(l.trim()) + { continue; } } @@ -1648,8 +1685,14 @@ mod tests { use super::*; use crate::app::plugin_host::HostSession; use crate::app::OpenCADStudio; - use ocs_doc_api::ops::{BoolOp, Color, LayerFlags, LayerInfo, LineWeight, SolidPrimitive}; - use ocs_doc_api::{DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt}; + use ocs_doc_api::ops::{ + BoolOp, Color, LayerFlags, LayerInfo, LineWeight, SolidPrimitive, XDataRecord, + XDataValue, XRecordEntry, XRecordSpec, XRecordValue, + }; + use ocs_doc_api::{ + DocApiEnvelope, HasId, ObjectId, Operation, Query, QueryResult, Receipt, + XRecordCloningFlags, + }; fn dispatch(host: &mut HostSession<'_>, env: DocApiEnvelope) -> ApiResult { let bytes = bincode::serialize(&env).unwrap(); @@ -4166,7 +4209,7 @@ mod tests { dispatch( &mut host, DocApiEnvelope::op(Operation::UpdateLayer { - name: "Walls".into(), + name: " Walls ".into(), info: updated.clone(), }), ) @@ -4210,7 +4253,7 @@ mod tests { dispatch( &mut host, DocApiEnvelope::op(Operation::DeleteLayer { - name: "Walls".into(), + name: " Walls ".into(), }), ) .unwrap(); @@ -4221,6 +4264,124 @@ mod tests { assert!(!list.iter().any(|l| l.name == "Walls")); } + #[test] + fn doc_api_xdata_batch_rejects_a_locked_entity_before_mutating() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let line = |host: &mut HostSession<'_>| { + new_id(&dispatch( + host, + DocApiEnvelope::op(Operation::CreateCurve(ocs_doc_api::Curve2Spec::Line { + start: [0.0; 3], + end: [1.0; 3], + layer: None, + })), + ).unwrap()) + }; + let first = line(&mut host); + let locked = line(&mut host); + + let mut layer = LayerInfo::new("Locked"); + dispatch(&mut host, DocApiEnvelope::op(Operation::CreateLayer(layer.clone()))).unwrap(); + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetEntityLayer { + id: locked, + layer: layer.name.clone(), + }), + ) + .unwrap(); + layer.flags.locked = true; + dispatch( + &mut host, + DocApiEnvelope::op(Operation::UpdateLayer { + name: layer.name.clone(), + info: layer, + }), + ) + .unwrap(); + + let record = |application_name: &str| XDataRecord { + application_name: application_name.into(), + values: vec![XDataValue::Integer32(42)], + }; + let result = dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetXDataMany(vec![ + (first, "FIRST".into(), Some(record("FIRST"))), + (locked, "LOCKED".into(), Some(record("LOCKED"))), + ])), + ); + assert!(matches!(result, Err(ApiError::Validation { .. }))); + assert!(host.xdata(first, "FIRST").unwrap().is_none()); + } + + #[test] + fn doc_api_xrecords_keep_one_named_owner() { + let mut app = OpenCADStudio::new_for_test(); + let mut host = HostSession::new(&mut app, 0); + let spec = |name: &str| XRecordSpec { + name: name.into(), + cloning_flags: XRecordCloningFlags::NotApplicable, + entries: vec![XRecordEntry { + code: 1, + value: XRecordValue::String("value".into()), + }], + }; + + let first = new_id(&dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateXRecord(spec("FIRST"))), + ).unwrap()); + let first_handle = obj_to_handle(first); + let ObjectType::XRecord(first_record) = host.document().objects.get(&first_handle).unwrap() + else { + panic!("expected XRecord") + }; + let owner = first_record.owner; + assert!(matches!(host.document().objects.get(&owner), Some(ObjectType::Dictionary(dict)) if + dict.entries.iter().any(|(name, child)| name == "FIRST" && *child == first_handle))); + + let object_count = host.document().objects.len(); + assert!(matches!( + dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateXRecord(spec("first"))), + ), + Err(ApiError::Validation { .. }) + )); + assert_eq!(host.document().objects.len(), object_count); + + let second = new_id(&dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateXRecord(spec("SECOND"))), + ).unwrap()); + assert!(matches!( + dispatch( + &mut host, + DocApiEnvelope::op(Operation::SetXRecord { + id: second, + spec: spec("FIRST"), + }), + ), + Err(ApiError::Validation { .. }) + )); + assert_eq!(host.xrecord(second).unwrap().unwrap().name, "SECOND"); + + let mut invalid = spec("INVALID"); + invalid.entries[0] = XRecordEntry { + code: 1, + value: XRecordValue::Int32(7), + }; + assert!(matches!( + dispatch( + &mut host, + DocApiEnvelope::op(Operation::CreateXRecord(invalid)), + ), + Err(ApiError::Validation { .. }) + )); + } + #[test] fn doc_api_enumerate_entities_and_geometry_queries() { let mut app = OpenCADStudio::new_for_test(); @@ -4297,7 +4458,7 @@ mod tests { &mut host, DocApiEnvelope::queries(vec![Query::EnumerateEntities { kind: None, - layer: Some("Markers".into()), + layer: Some(" Markers ".into()), include_bounds: true, }]), ) From a202a61ffe6079d781f54bd8b97c9f305f9bce72 Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:00:53 +0300 Subject: [PATCH 44/53] fix(display): regenerate existing solid isolines Rebuild cached solid meshes for REGEN and apply Options ISOLINES changes once when the slider is released. --- src/app/commands/display.rs | 120 +++++++++++++++++++++++++++++++++++- src/app/mod.rs | 7 +++ src/app/settings.rs | 52 ++++++++++++++++ src/app/update/file.rs | 27 +++++--- src/app/update/mod.rs | 97 +++++++++++++++++------------ src/ui/window/options.rs | 51 ++++++++------- 6 files changed, 281 insertions(+), 73 deletions(-) diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 908b02960..4a84ccba8 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -508,7 +508,18 @@ impl OpenCADStudio { // "modified" merely because tessellation caches were invalidated (C7). // REGENALL is functionally identical (C5). "REGEN" | "REGENALL" => { - self.tabs[i].scene.bump_geometry(); + // Solid meshes carry the isoline count they were tessellated + // with: `add_entity_internal` and `update_entity` pass + // `header.isolines` into `tessellate_volume` and store the + // result. Bumping the geometry epoch clears the wire memo but + // never re-runs that, so changing ISOLINES left every existing + // solid showing the density it was born with, and REGEN — the + // command whose whole job is this — did not help. + // + // `populate_meshes_from_document` re-tessellates from the + // document and bumps the epoch itself, so it replaces the bump + // rather than adding to it. + self.tabs[i].scene.populate_meshes_from_document(); self.command_line.push_output(crate::t!("REGEN: regenerated model.").as_ref()); return Some(Task::none()); } @@ -1893,3 +1904,110 @@ mod tests { assert!(!app.tabs[i].dirty, "REGENALL must not dirty the document either"); } } + +#[cfg(test)] +mod regen_tests { + use crate::app::OpenCADStudio; + + /// A solid's mesh carries the isoline count it was tessellated with: + /// `add_entity_internal` and `update_entity` pass `header.isolines` into + /// `tessellate_volume` and store the result. REGEN used to bump the + /// geometry epoch and stop, which clears the wire memo but leaves + /// `scene.meshes` exactly as it was — reported on #1123 as "REGEN should + /// fix this, but the isolines keep visible". + /// + /// Building ACIS geometry here would be out of proportion, so the mesh map + /// is seeded with a stale entry instead: REGEN must rebuild the map from + /// the document, and an entry belonging to no entity cannot survive that. + /// Bumping alone would leave it in place. + #[test] + fn regen_rebuilds_the_mesh_map_rather_than_only_bumping_the_epoch() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + // An entry belonging to no entity. Rebuilding the map from the document + // drops it; bumping the geometry epoch leaves it exactly where it is, + // which is the whole difference this fix turns on. + let stale = acadrust::Handle::new(0xDEAD); + app.tabs[i].scene.meshes.insert(stale, stale_mesh()); + let epoch_before = app.tabs[i].scene.geometry_epoch; + + let _ = app.run_command_line("REGEN"); + + assert!( + !app.tabs[i].scene.meshes.contains_key(&stale), + "REGEN must rebuild the mesh map, not leave what was there", + ); + assert_ne!( + app.tabs[i].scene.geometry_epoch, epoch_before, + "and it still has to bump the geometry epoch", + ); + } + + /// Dragging the isolines slider must not rebuild every solid's mesh on the + /// way — a drag emits a message per pixel, and the rebuild is the same work + /// REGEN does. It waits for the release, and only runs if the value moved. + #[test] + fn the_isolines_slider_rebuilds_once_on_release_and_not_while_dragging() { + use crate::app::Message; + + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let i = app.active_tab; + + let seed = |app: &mut OpenCADStudio, i: usize| { + let stale = acadrust::Handle::new(0xBEEF); + app.tabs[i].scene.meshes.insert(stale, stale_mesh()); + stale + }; + let stale = seed(&mut app, i); + + for value in [4i16, 3, 2, 1, 0] { + let _ = app.update(Message::IsolinesChanged(value)); + } + assert!( + app.tabs[i].scene.meshes.contains_key(&stale), + "the drag itself must not rebuild the meshes", + ); + assert_eq!(app.tabs[i].scene.document.header.isolines, 0); + + let _ = app.update(Message::IsolinesReleased); + assert!( + !app.tabs[i].scene.meshes.contains_key(&stale), + "releasing after a change rebuilds them", + ); + + // A release with nothing changed since the last one is a click that + // moved nothing, and must not pay for a rebuild. + let stale = seed(&mut app, i); + let _ = app.update(Message::IsolinesChanged(0)); + let _ = app.update(Message::IsolinesReleased); + assert!( + app.tabs[i].scene.meshes.contains_key(&stale), + "releasing without a change must rebuild nothing", + ); + } + + fn stale_mesh() -> crate::scene::model::mesh_model::MeshLodSet { + crate::scene::model::mesh_model::MeshLodSet { + lods: Vec::new(), + material: None, + face_materials: Default::default(), + visual_style: None, + complete: true, + edge_verts: Vec::new(), + edge_verts_low: Vec::new(), + curved_gens: Vec::new(), + metrics: Default::default(), + world_aabb: [0.0; 4], + z_aabb: [0.0; 2], + instance_source: None, + instance_transform: None, + instance_handle: None, + instance_color: None, + instance_aabb: None, + } + } +} + diff --git a/src/app/mod.rs b/src/app/mod.rs index eef45d546..3a87326f8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -517,6 +517,10 @@ pub(super) struct OpenCADStudio { crosshair_color: Option<[u8; 3]>, /// Editable Options buffer for the crosshair colour. crosshair_color_input: String, + /// Set while the isolines slider is being dragged with a value that + /// differs from the drawing's, so the costly mesh rebuild runs once on + /// release rather than on every pixel of the drag. + isolines_awaiting_regen: bool, /// Edit buffer for the SNAPANG field on the Options Drafting page. Kept /// separate from `snap_angle_deg` so a half-typed angle is not parsed. snap_angle_input: String, @@ -2048,6 +2052,8 @@ pub enum Message { OpenFolder(String), /// Change isolines per surface in the current drawing (ISOLINES). IsolinesChanged(i16), + /// The isolines slider was released; rebuild the meshes if it moved. + IsolinesReleased, /// Toggle silhouette edges in the current drawing (DISPSILH). DispSilhChanged(bool), /// Change surface density U in the current drawing (SURFU). @@ -3460,6 +3466,7 @@ impl OpenCADStudio { cursor_type: settings::CursorType::Crosshair, crosshair_color: None, crosshair_color_input: String::new(), + isolines_awaiting_regen: false, snap_angle_input: "0".to_string(), lineweight_display_scale: 100, isometric_drafting: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index 9db2d574c..dcacee388 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -145,6 +145,27 @@ pub(crate) fn snaps_from_osmode(osmode: i32) -> (Vec, bool) { (modes, osmode & OSMODE_SUPPRESS == 0) } +fn deserialize_options_tab<'de, D>( + deserializer: D, +) -> Result +where + D: serde::Deserializer<'de>, +{ + use crate::ui::window::options::OptionsTab; + use serde::Deserialize; + let name = String::deserialize(deserializer).unwrap_or_default(); + Ok(match name.as_str() { + "files" => OptionsTab::Files, + "open-and-save" => OptionsTab::OpenAndSave, + "display" => OptionsTab::Display, + "drafting" => OptionsTab::Drafting, + "modeling" => OptionsTab::Modeling, + "selection" => OptionsTab::Selection, + "user-preferences" => OptionsTab::UserPreferences, + _ => OptionsTab::General, + }) +} + /// Render a drafting angle without a trailing `.0`, so `22.5` but `30`. /// /// The polar pop-up formats its presets the same way; both are showing the @@ -183,6 +204,12 @@ pub struct UserSettings { /// all. 0 means no limit. The drawing header carries no slot for it. pub grip_object_limit: i32, /// Which Options page was showing when the dialog was last closed. + /// + /// Read leniently: `AppConfig::load` discards the *whole* file when any + /// field fails to parse, so a page name that no longer exists — a tab + /// renamed or merged away between versions — would silently reset every + /// setting the user has. An unknown name falls back to the first page. + #[serde(default, deserialize_with = "deserialize_options_tab")] pub options_tab: crate::ui::window::options::OptionsTab, /// Show the navigation cube (NAVVCUBE). pub show_viewcube: bool, @@ -427,4 +454,29 @@ mod tests { let b: std::collections::HashSet<_> = back.into_iter().collect(); assert_eq!(a, b); } + + /// `AppConfig::load` throws the whole file away when any field fails to + /// parse, so a page name that no longer exists would quietly reset every + /// preference the user has. This branch removed the Drawing page, and + /// anyone who had it open when they last closed the dialog has that name + /// on disk. + #[test] + fn a_page_name_that_no_longer_exists_does_not_cost_the_other_settings() { + let json = r#"{ + "settings": { + "options_tab": "drawing", + "pick_add": false, + "savetime_min": 42 + } + }"#; + let cfg: crate::app::config::AppConfig = + serde_json::from_str(json).expect("an unknown page name must not fail the parse"); + assert_eq!( + cfg.settings.options_tab, + crate::ui::window::options::OptionsTab::General, + ); + assert!(!cfg.settings.pick_add, "the rest of the file must survive"); + assert_eq!(cfg.settings.savetime_min, 42); + } } + diff --git a/src/app/update/file.rs b/src/app/update/file.rs index a98df8247..c8352555b 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -355,24 +355,37 @@ impl OpenCADStudio { } } - /// Write a tessellation-affecting header variable on the active drawing. + /// Write a header variable on the active drawing and mark it modified. /// - /// These decide how solids and surfaces are tessellated, so the geometry - /// has to be rebuilt for the change to appear — the same reason `LTSCALE` - /// bumps it. Marking the tab modified is what makes the new value reach - /// the file. - pub(in crate::app) fn set_drawing_tessellation_var( + /// Deliberately does no geometry work. The variables the Options pages + /// write differ in what they cost to apply, and treating them alike is + /// wrong in both directions: `DISPSILH` is read at render time and needs + /// nothing, `SURFU`/`SURFV`/`SURFTYPE` are inputs to PEDIT's smoothing and + /// need nothing until it runs, while `ISOLINES` is baked into solid meshes + /// at tessellation and needs more than a bump — see `regenerate_meshes`. + pub(in crate::app) fn set_drawing_var( &mut self, write: impl FnOnce(&mut acadrust::document::HeaderVariables), ) { let i = self.active_tab; if let Some(tab) = self.tabs.get_mut(i) { write(&mut tab.scene.document.header); - tab.scene.bump_geometry(); tab.dirty = true; } } + /// Re-tessellate the active drawing's solids from the document. + /// + /// What REGEN does, and the only thing that makes a changed `ISOLINES` + /// reach solids that already exist. Costly on a solid-heavy drawing, so + /// callers driven by a slider run it when the drag ends, not per tick. + pub(in crate::app) fn regenerate_meshes(&mut self) { + let i = self.active_tab; + if let Some(tab) = self.tabs.get_mut(i) { + tab.scene.populate_meshes_from_document(); + } + } + /// Rebuild text geometry in every open drawing. /// /// `TEXTFILL` is a process-global, so a change to it invalidates the text diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 3b4844fe5..e93374c43 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6305,54 +6305,73 @@ impl OpenCADStudio { Task::none() } - // The five below are drawing variables: they go into the header of - // the active drawing and mark it modified, exactly as SETVAR does. - // The four that feed solid tessellation also bump the geometry, or - // the change is invisible until an unrelated edit rebuilds it. + // Drawing variables: they go into the header of the active + // drawing and mark it modified, as SETVAR does. What each one + // costs to apply differs, so they are not treated alike. + // + // ISOLINES is baked into a solid's mesh when it is tessellated, so + // an existing solid keeps the density it was born with until the + // meshes are rebuilt. That is expensive, and a slider emits on + // every pixel of a drag, so the rebuild waits for the release — + // and only happens if the value actually moved. Message::IsolinesChanged(value) => { - self.set_drawing_tessellation_var(|header| header.isolines = value.max(0)); - Task::none() - } - - Message::DispSilhChanged(on) => { - self.set_drawing_tessellation_var(|header| header.display_silhouette = on); - Task::none() - } - - Message::SurfaceUChanged(value) => { - self.set_drawing_tessellation_var(|header| { - header.surface_u_density = value.clamp(0, 200) - }); - Task::none() - } - - Message::SurfaceVChanged(value) => { - self.set_drawing_tessellation_var(|header| { - header.surface_v_density = value.clamp(0, 200) - }); - Task::none() - } - - Message::SurfaceTypeChanged(value) => { - self.set_drawing_tessellation_var(|header| header.surface_type = value); - Task::none() - } - - Message::SolidHistChanged(record) => { - let i = self.active_tab; - if let Some(tab) = self.tabs.get_mut(i) { - tab.scene.document.header.record_solid_history = record; - tab.dirty = true; + let value = value.max(0); + let changed = self + .tabs + .get(self.active_tab) + .is_some_and(|tab| tab.scene.document.header.isolines != value); + if changed { + self.set_drawing_var(|header| header.isolines = value); + self.isolines_awaiting_regen = true; } Task::none() } + Message::IsolinesReleased => { + if std::mem::take(&mut self.isolines_awaiting_regen) { + self.regenerate_meshes(); + } + Task::none() + } + + // Read at render time, so nothing has to be rebuilt for it. + Message::DispSilhChanged(on) => { + self.set_drawing_var(|header| header.display_silhouette = on); + Task::none() + } + + // Inputs to PEDIT's mesh smoothing; they change nothing until it + // runs, so writing the header is the whole job. + Message::SurfaceUChanged(value) => { + self.set_drawing_var(|header| header.surface_u_density = value.clamp(0, 200)); + Task::none() + } + + Message::SurfaceVChanged(value) => { + self.set_drawing_var(|header| header.surface_v_density = value.clamp(0, 200)); + Task::none() + } + + Message::SurfaceTypeChanged(value) => { + self.set_drawing_var(|header| header.surface_type = value); + Task::none() + } + + // Whether history is recorded from now on; nothing to redraw. + Message::SolidHistChanged(record) => { + self.set_drawing_var(|header| header.record_solid_history = record); + Task::none() + } + + // Read while building geometry, so this one does bump — the same + // thing the SHOWHIST command does. Message::ShowHistChanged(mode) => { + self.set_drawing_var(|header| { + header.show_solid_history = mode.clamp(0, 2) + }); let i = self.active_tab; if let Some(tab) = self.tabs.get_mut(i) { - tab.scene.document.header.show_solid_history = mode.clamp(0, 2); tab.scene.bump_geometry(); - tab.dirty = true; } Task::none() } diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 0923ebe21..5c8210df8 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -32,7 +32,6 @@ pub enum OptionsTab { Modeling, Selection, UserPreferences, - Drawing, } /// Application preferences the dialog reads that are plain scalars on the app. @@ -1077,29 +1076,7 @@ pub fn view_window<'a>( .spacing(0) .width(sizing.width); - let drawing = column![ - text(crate::t!("Block Edit")).size(15), - Space::new().height(10), - row![ - iced::widget::checkbox(double_click_block_refedit) - .on_toggle(Message::DoubleClickBlockRefeditChanged) - .size(15), - text(crate::t!("Double-click to edit block in-place (REFEDIT)")).size(12), - ] - .spacing(8) - .align_y(iced::Center), - Space::new().height(18), - row![ - iced::widget::checkbox(double_click_block_attedit) - .on_toggle(Message::DoubleClickBlockAtteditChanged) - .size(15), - text(crate::t!("Double-click attributed blocks to edit attributes (ATTEDIT)")).size(12), - ] - .spacing(8) - .align_y(iced::Center), - ] - .spacing(0) - .width(sizing.width); + // ANNOAUTOSCALE's magnitude decides which objects a newly added scale @@ -1225,6 +1202,26 @@ pub fn view_window<'a>( .size(11) .width(sizing.width), Space::new().height(24), + text(crate::t!("Block Edit")).size(15), + Space::new().height(10), + row![ + iced::widget::checkbox(double_click_block_refedit) + .on_toggle(Message::DoubleClickBlockRefeditChanged) + .size(15), + text(crate::t!("Double-click to edit block in-place (REFEDIT)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(18), + row![ + iced::widget::checkbox(double_click_block_attedit) + .on_toggle(Message::DoubleClickBlockAtteditChanged) + .size(15), + text(crate::t!("Double-click attributed blocks to edit attributes (ATTEDIT)")).size(12), + ] + .spacing(8) + .align_y(iced::Center), + Space::new().height(24), text(crate::t!("Drawing Units")).size(15), Space::new().height(10), row![ @@ -1394,12 +1391,16 @@ pub fn view_window<'a>( .push( row![ text(crate::t!("Isolines per surface")).size(12).width(150), + // Rebuilding every solid's mesh is what makes a new + // isoline count reach solids that already exist, and it is + // far too costly to do on each pixel of a drag. slider( 0..=64, drawing_prefs.isolines.clamp(0, 64), Message::IsolinesChanged, ) .step(1i16) + .on_release(Message::IsolinesReleased) .width(Fill), text(drawing_prefs.isolines.clamp(0, 64).to_string()) .size(11) @@ -1565,7 +1566,6 @@ pub fn view_window<'a>( OptionsTab::Drafting => drafting.into(), OptionsTab::Modeling => modeling.into(), OptionsTab::UserPreferences => user_prefs.into(), - OptionsTab::Drawing => drawing.into(), }; // A vertical rail rather than a horizontal strip: the tab names are @@ -1588,7 +1588,6 @@ pub fn view_window<'a>( tab_button(crate::t!("3D Modeling"), OptionsTab::Modeling), tab_button(crate::t!("Selection"), OptionsTab::Selection), tab_button(crate::t!("User Preferences"), OptionsTab::UserPreferences), - tab_button(crate::t!("Drawing"), OptionsTab::Drawing), ] .spacing(2) .width(iced::Length::Fixed(TAB_RAIL_WIDTH)); From 5ffbf598a90d1ddd19c6f6b37061538b389a6bde Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 18:05:42 +0300 Subject: [PATCH 45/53] refactor(display): trim isolines follow-up comments --- src/app/commands/display.rs | 46 +++---------------------------------- src/app/mod.rs | 4 +--- src/app/settings.rs | 13 +---------- src/app/update/file.rs | 13 +---------- src/app/update/mod.rs | 16 +------------ src/ui/window/options.rs | 7 +----- 6 files changed, 8 insertions(+), 91 deletions(-) diff --git a/src/app/commands/display.rs b/src/app/commands/display.rs index 4a84ccba8..91cdaca00 100644 --- a/src/app/commands/display.rs +++ b/src/app/commands/display.rs @@ -502,23 +502,8 @@ impl OpenCADStudio { self.command_line.push_output(crate::t!("REDRAWALL: viewports refreshed.").as_ref()); return Some(Task::none()); } - // REGEN — full model regeneration (bump_geometry: geometry_epoch AND - // block_epoch; C4). No undo, no DB mutation, so do NOT touch - // self.tabs[i].dirty — a newly opened drawing must not become - // "modified" merely because tessellation caches were invalidated (C7). - // REGENALL is functionally identical (C5). + // Rebuild tessellation caches without modifying the document. "REGEN" | "REGENALL" => { - // Solid meshes carry the isoline count they were tessellated - // with: `add_entity_internal` and `update_entity` pass - // `header.isolines` into `tessellate_volume` and store the - // result. Bumping the geometry epoch clears the wire memo but - // never re-runs that, so changing ISOLINES left every existing - // solid showing the density it was born with, and REGEN — the - // command whose whole job is this — did not help. - // - // `populate_meshes_from_document` re-tessellates from the - // document and bumps the epoch itself, so it replaces the bump - // rather than adding to it. self.tabs[i].scene.populate_meshes_from_document(); self.command_line.push_output(crate::t!("REGEN: regenerated model.").as_ref()); return Some(Task::none()); @@ -1903,32 +1888,13 @@ mod tests { let _ = app.run_command_line("REGENALL"); assert!(!app.tabs[i].dirty, "REGENALL must not dirty the document either"); } -} -#[cfg(test)] -mod regen_tests { - use crate::app::OpenCADStudio; - - /// A solid's mesh carries the isoline count it was tessellated with: - /// `add_entity_internal` and `update_entity` pass `header.isolines` into - /// `tessellate_volume` and store the result. REGEN used to bump the - /// geometry epoch and stop, which clears the wire memo but leaves - /// `scene.meshes` exactly as it was — reported on #1123 as "REGEN should - /// fix this, but the isolines keep visible". - /// - /// Building ACIS geometry here would be out of proportion, so the mesh map - /// is seeded with a stale entry instead: REGEN must rebuild the map from - /// the document, and an entry belonging to no entity cannot survive that. - /// Bumping alone would leave it in place. #[test] fn regen_rebuilds_the_mesh_map_rather_than_only_bumping_the_epoch() { let mut app = OpenCADStudio::new_for_test(); app.automation_op(r#"{"op":"new"}"#); let i = app.active_tab; - // An entry belonging to no entity. Rebuilding the map from the document - // drops it; bumping the geometry epoch leaves it exactly where it is, - // which is the whole difference this fix turns on. let stale = acadrust::Handle::new(0xDEAD); app.tabs[i].scene.meshes.insert(stale, stale_mesh()); let epoch_before = app.tabs[i].scene.geometry_epoch; @@ -1937,17 +1903,14 @@ mod regen_tests { assert!( !app.tabs[i].scene.meshes.contains_key(&stale), - "REGEN must rebuild the mesh map, not leave what was there", + "REGEN left a stale mesh", ); assert_ne!( app.tabs[i].scene.geometry_epoch, epoch_before, - "and it still has to bump the geometry epoch", + "REGEN did not bump the geometry epoch", ); } - /// Dragging the isolines slider must not rebuild every solid's mesh on the - /// way — a drag emits a message per pixel, and the rebuild is the same work - /// REGEN does. It waits for the release, and only runs if the value moved. #[test] fn the_isolines_slider_rebuilds_once_on_release_and_not_while_dragging() { use crate::app::Message; @@ -1978,8 +1941,6 @@ mod regen_tests { "releasing after a change rebuilds them", ); - // A release with nothing changed since the last one is a click that - // moved nothing, and must not pay for a rebuild. let stale = seed(&mut app, i); let _ = app.update(Message::IsolinesChanged(0)); let _ = app.update(Message::IsolinesReleased); @@ -2010,4 +1971,3 @@ mod regen_tests { } } } - diff --git a/src/app/mod.rs b/src/app/mod.rs index 3a87326f8..0c2cd3e73 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -517,9 +517,7 @@ pub(super) struct OpenCADStudio { crosshair_color: Option<[u8; 3]>, /// Editable Options buffer for the crosshair colour. crosshair_color_input: String, - /// Set while the isolines slider is being dragged with a value that - /// differs from the drawing's, so the costly mesh rebuild runs once on - /// release rather than on every pixel of the drag. + /// Defer the ISOLINES mesh rebuild until the slider is released. isolines_awaiting_regen: bool, /// Edit buffer for the SNAPANG field on the Options Drafting page. Kept /// separate from `snap_angle_deg` so a half-typed angle is not parsed. diff --git a/src/app/settings.rs b/src/app/settings.rs index dcacee388..f40d41d54 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -203,12 +203,7 @@ pub struct UserSettings { /// GRIPOBJLIMIT: past this many selected objects, no grips are drawn at /// all. 0 means no limit. The drawing header carries no slot for it. pub grip_object_limit: i32, - /// Which Options page was showing when the dialog was last closed. - /// - /// Read leniently: `AppConfig::load` discards the *whole* file when any - /// field fails to parse, so a page name that no longer exists — a tab - /// renamed or merged away between versions — would silently reset every - /// setting the user has. An unknown name falls back to the first page. + /// Last Options page; unknown saved names fall back without rejecting the config. #[serde(default, deserialize_with = "deserialize_options_tab")] pub options_tab: crate::ui::window::options::OptionsTab, /// Show the navigation cube (NAVVCUBE). @@ -455,11 +450,6 @@ mod tests { assert_eq!(a, b); } - /// `AppConfig::load` throws the whole file away when any field fails to - /// parse, so a page name that no longer exists would quietly reset every - /// preference the user has. This branch removed the Drawing page, and - /// anyone who had it open when they last closed the dialog has that name - /// on disk. #[test] fn a_page_name_that_no_longer_exists_does_not_cost_the_other_settings() { let json = r#"{ @@ -479,4 +469,3 @@ mod tests { assert_eq!(cfg.settings.savetime_min, 42); } } - diff --git a/src/app/update/file.rs b/src/app/update/file.rs index c8352555b..518d5c4a5 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -356,13 +356,6 @@ impl OpenCADStudio { } /// Write a header variable on the active drawing and mark it modified. - /// - /// Deliberately does no geometry work. The variables the Options pages - /// write differ in what they cost to apply, and treating them alike is - /// wrong in both directions: `DISPSILH` is read at render time and needs - /// nothing, `SURFU`/`SURFV`/`SURFTYPE` are inputs to PEDIT's smoothing and - /// need nothing until it runs, while `ISOLINES` is baked into solid meshes - /// at tessellation and needs more than a bump — see `regenerate_meshes`. pub(in crate::app) fn set_drawing_var( &mut self, write: impl FnOnce(&mut acadrust::document::HeaderVariables), @@ -374,11 +367,7 @@ impl OpenCADStudio { } } - /// Re-tessellate the active drawing's solids from the document. - /// - /// What REGEN does, and the only thing that makes a changed `ISOLINES` - /// reach solids that already exist. Costly on a solid-heavy drawing, so - /// callers driven by a slider run it when the drag ends, not per tick. + /// Re-tessellate active-drawing solids after an ISOLINES change. pub(in crate::app) fn regenerate_meshes(&mut self) { let i = self.active_tab; if let Some(tab) = self.tabs.get_mut(i) { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index e93374c43..3fbe1ccf6 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -6305,15 +6305,7 @@ impl OpenCADStudio { Task::none() } - // Drawing variables: they go into the header of the active - // drawing and mark it modified, as SETVAR does. What each one - // costs to apply differs, so they are not treated alike. - // - // ISOLINES is baked into a solid's mesh when it is tessellated, so - // an existing solid keeps the density it was born with until the - // meshes are rebuilt. That is expensive, and a slider emits on - // every pixel of a drag, so the rebuild waits for the release — - // and only happens if the value actually moved. + // ISOLINES is baked into solid meshes, so rebuild once on release. Message::IsolinesChanged(value) => { let value = value.max(0); let changed = self @@ -6334,14 +6326,11 @@ impl OpenCADStudio { Task::none() } - // Read at render time, so nothing has to be rebuilt for it. Message::DispSilhChanged(on) => { self.set_drawing_var(|header| header.display_silhouette = on); Task::none() } - // Inputs to PEDIT's mesh smoothing; they change nothing until it - // runs, so writing the header is the whole job. Message::SurfaceUChanged(value) => { self.set_drawing_var(|header| header.surface_u_density = value.clamp(0, 200)); Task::none() @@ -6357,14 +6346,11 @@ impl OpenCADStudio { Task::none() } - // Whether history is recorded from now on; nothing to redraw. Message::SolidHistChanged(record) => { self.set_drawing_var(|header| header.record_solid_history = record); Task::none() } - // Read while building geometry, so this one does bump — the same - // thing the SHOWHIST command does. Message::ShowHistChanged(mode) => { self.set_drawing_var(|header| { header.show_solid_history = mode.clamp(0, 2) diff --git a/src/ui/window/options.rs b/src/ui/window/options.rs index 5c8210df8..3c28d3700 100644 --- a/src/ui/window/options.rs +++ b/src/ui/window/options.rs @@ -1076,9 +1076,6 @@ pub fn view_window<'a>( .spacing(0) .width(sizing.width); - - - // ANNOAUTOSCALE's magnitude decides which objects a newly added scale // reaches — 1 skips layers that are off, frozen, locked or frozen in the // viewport, 2 keeps locked ones, 3 skips only locked, 4 takes everything. @@ -1391,9 +1388,7 @@ pub fn view_window<'a>( .push( row![ text(crate::t!("Isolines per surface")).size(12).width(150), - // Rebuilding every solid's mesh is what makes a new - // isoline count reach solids that already exist, and it is - // far too costly to do on each pixel of a drag. + // Retessellate once when dragging ends. slider( 0..=64, drawing_prefs.isolines.clamp(0, 64), From d47e3234d85e112ec9cd515b0e207360d37c43e1 Mon Sep 17 00:00:00 2001 From: MarcelP102 Date: Thu, 10 Sep 2026 17:23:28 +0200 Subject: [PATCH 46/53] Add dynamic input for grip-based geometry editing (#1138) --- src/app/commands/mod.rs | 4 ++ src/app/update/command.rs | 135 +++++++++++++++++++++++++++++++++---- src/app/update/dynamic.rs | 49 ++++++++++---- src/app/update/mod.rs | 18 +++++ src/app/update/viewport.rs | 23 +++++-- src/app/view/mod.rs | 46 +++++++++++-- src/entities/arc.rs | 21 +++++- src/entities/circle.rs | 107 ++++++++++++++++++++++++++++- src/scene/pick/grip.rs | 14 ++++ 9 files changed, 378 insertions(+), 39 deletions(-) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 3c6a00624..5fbad18e4 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -75,6 +75,10 @@ impl OpenCADStudio { .dispatch_families(cmd, i) .unwrap_or_else(Task::none); } + // A new command owns subsequent keyboard input. Any unfinished grip + // menu value prompt must not survive and intercept that command's + // coordinates or its final empty Enter. + self.grip_pending = None; // Starting a command closes any open ribbon dropdown (e.g. a style // combo left open) so it does not stay stuck behind the new tool. self.ribbon.close_dropdown(); diff --git a/src/app/update/command.rs b/src/app/update/command.rs index a5b76642b..b6ec4808f 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -199,6 +199,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .buffer .get_or_insert_with(String::new) .push_str(&s); + if self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + ) + }) { + self.command_line.input.push_str(&s); + } } else { // Command-line entry is shown uppercase — except in // free-form text prompts, where the typed case is the @@ -242,6 +252,16 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { if buf.is_empty() { self.tabs[i].dyn_fields[a].buffer = None; } + if self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + GripEditMode::Lengthen + | GripEditMode::Radius + | GripEditMode::ArcLength + ) + }) { + self.command_line.input.pop(); + } return self.focus_cmd_input(); } } @@ -277,13 +297,49 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } // Grip-menu value prompt — consume the typed number and // route it through `apply_grip_menu_value`. + // Interactive grip prompts are valid only while their matching + // grip edit is alive. Do not let a stale Radius / Lengthen / + // Arc Length prompt consume Enter or coordinates from a later + // drawing command. + let stale_interactive_grip_prompt = self.grip_pending.as_ref().is_some_and(|pending| { + let expected_mode = match pending.action { + crate::scene::model::object::GripMenuAction::Lengthen => { + Some(GripEditMode::Lengthen) + } + crate::scene::model::object::GripMenuAction::Radius => { + Some(GripEditMode::Radius) + } + crate::scene::model::object::GripMenuAction::ArcLength => { + Some(GripEditMode::ArcLength) + } + _ => None, + }; + expected_mode.is_some_and(|mode| { + !self.tabs[self.active_tab].active_grip.as_ref().is_some_and(|grip| { + grip.mode == mode + && grip.handle == pending.handle + && grip.grip_id == pending.grip_id + }) + }) + }); + if stale_interactive_grip_prompt { + self.grip_pending = None; + } if let Some(pending) = self.grip_pending.take() { let i = self.active_tab; if self.reject_locked_edit(i, pending.handle) { self.cancel_active_grip_edit(); return Task::none(); } - let raw = crate::app::expr_eval::eval_to_string(self.command_line.input.trim()); + // Dynamic Input keeps numeric typing in its focused field. + // Fall back to the command-line buffer so the established + // prompt workflow remains unchanged when DYN is disabled. + let dyn_value = self.tabs[i] + .dyn_fields + .iter() + .find_map(|field| field.buffer.as_deref()); + let entered = dyn_value.unwrap_or(self.command_line.input.trim()); + let raw = crate::app::expr_eval::eval_to_string(entered); self.command_line.input.clear(); let Ok(v) = raw.parse::() else { self.command_line.push_error(crate::tf!( @@ -293,15 +349,27 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.grip_pending = Some(pending); return self.focus_cmd_input(); }; - let interactive_lengthen = self.tabs[i] + let interactive_value_grip = self.tabs[i] .active_grip .as_ref() .is_some_and(|grip| { - grip.mode == GripEditMode::Lengthen + matches!( + (grip.mode, pending.action), + ( + GripEditMode::Lengthen, + crate::scene::model::object::GripMenuAction::Lengthen, + ) | ( + GripEditMode::Radius, + crate::scene::model::object::GripMenuAction::Radius, + ) | ( + GripEditMode::ArcLength, + crate::scene::model::object::GripMenuAction::ArcLength, + ) + ) && grip.handle == pending.handle && grip.grip_id == pending.grip_id }); - if interactive_lengthen { + if interactive_value_grip { self.cancel_active_grip_edit(); } use crate::entities::traits::EntityTypeOps; @@ -1306,7 +1374,12 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { action: item.action, label, }); - if matches!(item.action, GripMenuAction::Lengthen) { + if matches!( + item.action, + GripMenuAction::Lengthen + | GripMenuAction::Radius + | GripMenuAction::ArcLength + ) { if let Some((_, grip)) = self.tabs[i] .selected_grip_handles .iter() @@ -1315,14 +1388,52 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { **owner == popup.handle && grip.id == popup.grip_id }) { - self.tabs[i].active_grip = Some(GripEdit::lengthen( - popup.handle, - popup.grip_id, - grip.world, - )); + if self.grip_originals.is_empty() { + self.grip_originals = self.tabs[i] + .scene + .document + .get_entity(popup.handle) + .cloned() + .map(|entity| vec![(popup.handle, entity)]) + .unwrap_or_default(); + } + self.tabs[i].active_grip = Some(match item.action { + GripMenuAction::Radius => GripEdit::radius( + popup.handle, + popup.grip_id, + grip.world, + ), + GripMenuAction::ArcLength => GripEdit::arc_length( + popup.handle, + popup.grip_id, + grip.world, + ), + _ => GripEdit::lengthen( + popup.handle, + popup.grip_id, + grip.world, + ), + }); + } + // Popup actions do not pass through the normal grip + // press handler, which is where dynamic fields are + // usually seeded. Build the value field + // immediately so it is visible before the next mouse + // move (and so keyboard input has a field to target). + self.sync_dyn_fields(); + if matches!(item.action, GripMenuAction::Radius) { + self.command_line.push_info( + crate::t!("Specify point or enter radius:").as_ref(), + ); + } else if matches!(item.action, GripMenuAction::ArcLength) { + self.command_line.push_info( + crate::t!("Specify point or enter arc length:").as_ref(), + ); + } else { + self.command_line.push_info( + crate::t!("Specify point or enter distance:").as_ref(), + ); } - self.command_line - .push_info(crate::t!("Specify point or enter distance:").as_ref()); } else { self.command_line.push_info(crate::tf!("{label}:").as_ref()); } diff --git a/src/app/update/dynamic.rs b/src/app/update/dynamic.rs index 1e1888055..bfe201e6a 100644 --- a/src/app/update/dynamic.rs +++ b/src/app/update/dynamic.rs @@ -65,34 +65,55 @@ impl OpenCADStudio { // A normal 2D grip stretch behaves like a point-placement step: // distance and angle are measured from the grip's original position. + // Lengthen uses the same overlay machinery, but it only needs the + // distance field: the entity itself determines the endpoint direction + // (or arc sweep), so an angle entry would be misleading. // // Grip editing is not an `active_cmd`, so handle it before the normal // command-only path below. - let grip_origin = self.tabs[i] + let grip_input = self.tabs[i] .active_grip .as_ref() - .filter(|grip| { - grip.mode == crate::scene::pick::grip::GripEditMode::Stretch - }) - .map(|grip| grip.origin_world); + .map(|grip| match grip.mode { + crate::scene::pick::grip::GripEditMode::Stretch => { + (grip.origin_world, None) + } + crate::scene::pick::grip::GripEditMode::Lengthen => { + (grip.origin_world, Some(crate::command::DynRole::Distance)) + } + crate::scene::pick::grip::GripEditMode::Radius => { + (grip.origin_world, Some(crate::command::DynRole::Radius)) + } + crate::scene::pick::grip::GripEditMode::ArcLength => { + (grip.origin_world, Some(crate::command::DynRole::Distance)) + } + }); - if let Some(origin) = grip_origin { - let wanted = [DynComponent::Distance, DynComponent::Angle]; - let current: Vec = self.tabs[i] + if let Some((origin, scalar_role)) = grip_input { + let wanted_roles: Vec = scalar_role.map_or_else( + || vec![crate::command::DynRole::Distance, crate::command::DynRole::Angle], + |role| vec![role], + ); + let current: Vec = self.tabs[i] .dyn_fields .iter() - .map(|field| field.component) + .map(|field| field.role) .collect(); - if current.as_slice() != wanted { - self.tabs[i].dyn_fields = wanted - .into_iter() - .map(DynFieldEntry::new) + if current != wanted_roles { + self.tabs[i].dyn_fields = wanted_roles + .iter() + .copied() + .map(DynFieldEntry::from_role) .collect(); self.tabs[i].dyn_active = 0; } - self.tabs[i].dyn_guide = crate::command::DynGuide::Polar; + self.tabs[i].dyn_guide = if scalar_role.is_some() { + crate::command::DynGuide::Radius + } else { + crate::command::DynGuide::Polar + }; self.tabs[i].dyn_anchor = Some(origin); self.tabs[i].dyn_ref = None; return; diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 3fbe1ccf6..f84da07ef 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1583,6 +1583,24 @@ impl OpenCADStudio { } let live_input = s.clone(); self.command_line.input = live_input.clone(); + let i = self.active_tab; + if self.dyn_input + && self.tabs[i].active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + crate::scene::pick::grip::GripEditMode::Lengthen + | crate::scene::pick::grip::GripEditMode::Radius + | crate::scene::pick::grip::GripEditMode::ArcLength + ) + }) + && !self.tabs[i].dyn_fields.is_empty() + { + let a = self.tabs[i] + .dyn_active + .min(self.tabs[i].dyn_fields.len() - 1); + self.tabs[i].dyn_fields[a].buffer = + (!live_input.is_empty()).then_some(live_input.clone()); + } self.command_line.autocomplete_cursor = None; self.command_line.cancel_history_navigation(); // Live incremental search for INSERT/MINSERT: update picker on each keystroke diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index e6b282c80..606ffa871 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -1580,8 +1580,19 @@ impl OpenCADStudio { let apply_started = Instant::now(); let delta = snapped - grip.last_world; - let lengthen = grip.mode == GripEditMode::Lengthen; - let actions: Vec<_> = if lengthen { + let menu_action = match grip.mode { + GripEditMode::Lengthen => { + Some(crate::scene::model::object::GripMenuAction::Lengthen) + } + GripEditMode::Radius => { + Some(crate::scene::model::object::GripMenuAction::Radius) + } + GripEditMode::ArcLength => { + Some(crate::scene::model::object::GripMenuAction::ArcLength) + } + GripEditMode::Stretch => None, + }; + let actions: Vec<_> = if menu_action.is_some() { Vec::new() } else { grip.targets @@ -1596,14 +1607,13 @@ impl OpenCADStudio { }) .collect() }; - if lengthen { + if let Some(action) = menu_action { let original = self .grip_originals .iter() .find(|(handle, _)| *handle == grip.handle) .map(|(_, entity)| entity.clone()); if let Some(original) = original { - let action = crate::scene::model::object::GripMenuAction::Lengthen; let value = crate::scene::view::dispatch::grip_menu_point_value( &original, grip.grip_id, @@ -3232,7 +3242,10 @@ impl OpenCADStudio { // Engaging click — stay hot, wait for the placement click. return Task::none(); } - if grip.mode == GripEditMode::Lengthen { + if matches!( + grip.mode, + GripEditMode::Lengthen | GripEditMode::Radius | GripEditMode::ArcLength + ) { self.grip_pending = None; self.command_line.input.clear(); } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3373e1aeb..3c33785d5 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -927,7 +927,32 @@ bg={bg_ms:.1}ms n={view_count}" // A command may drive a typed scalar by mouse (e.g. a // perpendicular distance to a picked object); show that live // value in the box until the user types over it. - let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)); + let live = tab.active_cmd.as_ref().and_then(|c| c.dyn_live_value(w)).or_else(|| { + let grip = tab.active_grip.as_ref()?; + let action = match grip.mode { + crate::scene::pick::grip::GripEditMode::Lengthen => { + crate::scene::model::object::GripMenuAction::Lengthen + } + crate::scene::pick::grip::GripEditMode::Radius => { + crate::scene::model::object::GripMenuAction::Radius + } + crate::scene::pick::grip::GripEditMode::ArcLength => { + crate::scene::model::object::GripMenuAction::ArcLength + } + _ => return None, + }; + let original = self + .grip_originals + .iter() + .find(|(handle, _)| *handle == grip.handle) + .map(|(_, entity)| entity)?; + crate::scene::view::dispatch::grip_menu_point_value( + original, + grip.grip_id, + action, + w, + ) + }); let boxes: Vec = tab .dyn_fields .iter() @@ -1687,17 +1712,28 @@ bg={bg_ms:.1}ms n={view_count}" // viewport stack rather than as a separate row in the main // column — frees up vertical space when no command is active // and keeps the input close to where the cursor is drawing. - // Autocomplete shows only when no command is collecting its - // own input (otherwise typed prefixes are coordinates / values). - let allow_autocomplete = tab.active_cmd.is_none(); + // Autocomplete shows only when no command or grip-menu action is + // collecting its own input. A pending Lengthen / Radius / Arc Length + // value is numeric input, not the prefix of a new command (for example, + // `3` must not open the 3DALIGN / 3DARRAY suggestions). + let allow_autocomplete = tab.active_cmd.is_none() && self.grip_pending.is_none(); // Dynamic input captures keystrokes when its fields are showing, // so the command-line field must release focus / its on_input. // The MText preview also captures keystrokes (typing edits it), so the // command line must likewise release its on_input there. + let interactive_value_grip = tab.active_grip.as_ref().is_some_and(|grip| { + matches!( + grip.mode, + crate::scene::pick::grip::GripEditMode::Lengthen + | crate::scene::pick::grip::GripEditMode::Radius + | crate::scene::pick::grip::GripEditMode::ArcLength + ) + }); let dyn_capturing = (self.dyn_input && (tab.active_cmd.is_some() || tab.active_grip.is_some()) - && !tab.dyn_fields.is_empty()) + && !tab.dyn_fields.is_empty() + && !interactive_value_grip) || self.mtext_editor.as_ref().is_some_and(|e| e.show_preview) || self.text_inline.is_some(); // The workspace row is: left edge stack, viewport, right edge stack. diff --git a/src/entities/arc.rs b/src/entities/arc.rs index 3f2ffaffd..b9e0b1172 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -365,13 +365,32 @@ impl crate::entities::traits::Grippable for Arc { point: glam::DVec3, ) -> Option { use crate::scene::model::object::GripMenuAction as A; - if !matches!(action, A::Lengthen) || self.radius <= 1.0e-9 { + if self.radius <= 1.0e-9 { return None; } let (x, y, _) = crate::scene::view::transform::wcs_point_to_ocs( (point.x, point.y, point.z), (self.normal.x, self.normal.y, self.normal.z), ); + if matches!(action, A::Radius) && grip_id == 3 { + let radius = ((x - self.center.x).powi(2) + (y - self.center.y).powi(2)).sqrt(); + return (radius > 1.0e-9).then_some(radius); + } + if matches!(action, A::ArcLength) && grip_id == 3 { + let sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); + let middle_angle = self.start_angle + sweep * 0.5; + let middle_x = self.center.x + self.radius * middle_angle.cos(); + let middle_y = self.center.y + self.radius * middle_angle.sin(); + let tangent_x = -middle_angle.sin(); + let tangent_y = middle_angle.cos(); + let delta = (x - middle_x) * tangent_x + (y - middle_y) * tangent_y; + let length = self.radius * sweep + delta; + return (length > 1.0e-9 && length < self.radius * TAU - 1.0e-9) + .then_some(length); + } + if !matches!(action, A::Lengthen) { + return None; + } let cursor_angle = (y - self.center.y).atan2(x - self.center.x); let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); let desired_sweep = match grip_id { diff --git a/src/entities/circle.rs b/src/entities/circle.rs index bc037abfb..7d596506e 100644 --- a/src/entities/circle.rs +++ b/src/entities/circle.rs @@ -256,7 +256,89 @@ impl RenderConvertible for Circle { } } -crate::impl_entity_basics!(Circle); +impl crate::entities::traits::Grippable for Circle { + fn grips(&self) -> Vec { + grips(self) + } + + fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + apply_grip(self, grip_id, apply); + } + + fn grip_menu( + &self, + grip_id: usize, + ) -> Vec { + use crate::scene::model::object::{GripMenuAction, GripMenuItem}; + let mut items = vec![GripMenuItem { + label: "Stretch", + action: GripMenuAction::Stretch, + }]; + if (1..=4).contains(&grip_id) { + items.push(GripMenuItem { + label: "Radius", + action: GripMenuAction::Radius, + }); + } + items + } + + fn grip_menu_value_prompt( + &self, + _grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + ) -> Option<&'static str> { + matches!(action, crate::scene::model::object::GripMenuAction::Radius) + .then_some("New radius") + } + + fn grip_menu_point_value( + &self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + point: glam::DVec3, + ) -> Option { + if !matches!(action, crate::scene::model::object::GripMenuAction::Radius) + || !(1..=4).contains(&grip_id) + { + return None; + } + let plane = crate::entities::curve::circle_curve(self).plane; + let point = plane.project(point.to_array())?; + let radius = (point[0] - self.center.x).hypot(point[1] - self.center.y); + (radius > 1.0e-9).then_some(radius) + } + + fn apply_grip_menu_value( + &mut self, + grip_id: usize, + action: crate::scene::model::object::GripMenuAction, + value: f64, + ) { + if matches!(action, crate::scene::model::object::GripMenuAction::Radius) + && (1..=4).contains(&grip_id) + && value > 1.0e-9 + { + self.radius = value; + } + } +} + +impl crate::entities::traits::PropertyEditable for Circle { + fn geometry_properties(&self, _text_style_names: &[String]) -> Vec { + properties(self) + } + + fn apply_geom_prop(&mut self, field: &str, value: &str) { + apply_geom_prop(self, field, value); + } +} + +impl crate::entities::traits::Transformable for Circle { + fn apply_transform(&mut self, transform: &EntityTransform) { + apply_transform(self, transform); + } +} impl crate::entities::traits::MassPropsCalc for Circle { fn mass_props(&self) -> crate::entities::traits::MassProps { @@ -276,6 +358,28 @@ impl crate::entities::traits::MassPropsCalc for Circle { mod tests { use super::*; + #[test] + fn quadrant_grips_offer_interactive_radius() { + use crate::entities::traits::Grippable; + use crate::scene::model::object::GripMenuAction; + + let mut circle = Circle::default(); + circle.radius = 2.0; + + assert_eq!(circle.grip_menu(0).len(), 1); + assert!(circle + .grip_menu(1) + .iter() + .any(|item| item.action == GripMenuAction::Radius)); + assert_eq!( + circle.grip_menu_value_prompt(1, GripMenuAction::Radius), + Some("New radius") + ); + + circle.apply_grip_menu_value(1, GripMenuAction::Radius, 5.0); + assert_eq!(circle.radius, 5.0); + } + #[test] fn test_circle_segments_scales_with_zoom() { let r = 10.0; @@ -361,4 +465,3 @@ mod tests { assert!(close_count > far_count, "{close_count} <= {far_count}"); } } - diff --git a/src/scene/pick/grip.rs b/src/scene/pick/grip.rs index e8ff3e72f..623eedaff 100644 --- a/src/scene/pick/grip.rs +++ b/src/scene/pick/grip.rs @@ -54,6 +54,8 @@ pub struct GripEdit { pub enum GripEditMode { Stretch, Lengthen, + Radius, + ArcLength, } #[derive(Clone, Debug)] @@ -92,6 +94,18 @@ impl GripEdit { edit.mode = GripEditMode::Lengthen; edit } + + pub fn radius(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::Radius; + edit + } + + pub fn arc_length(handle: Handle, grip_id: usize, world: DVec3) -> Self { + let mut edit = Self::single(handle, grip_id, false, world); + edit.mode = GripEditMode::ArcLength; + edit + } } // ── Screen-space helpers ─────────────────────────────────────────────────── From a8645c2d9502f9dca6b748d0c2b99122401a835d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 18:37:55 +0300 Subject: [PATCH 47/53] fix(grips): cancel value edits before new commands --- src/app/commands/mod.rs | 44 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/src/app/commands/mod.rs b/src/app/commands/mod.rs index 5fbad18e4..e3650b5d1 100644 --- a/src/app/commands/mod.rs +++ b/src/app/commands/mod.rs @@ -75,10 +75,12 @@ impl OpenCADStudio { .dispatch_families(cmd, i) .unwrap_or_else(Task::none); } - // A new command owns subsequent keyboard input. Any unfinished grip - // menu value prompt must not survive and intercept that command's - // coordinates or its final empty Enter. - self.grip_pending = None; + // A new command abandons any grip edit and its numeric input. + let had_pending_grip_input = self.grip_pending.take().is_some(); + let had_active_grip = self.cancel_active_grip_edit(); + if had_pending_grip_input || had_active_grip { + self.command_line.input.clear(); + } // Starting a command closes any open ribbon dropdown (e.g. a style // combo left open) so it does not stay stuck behind the new tool. self.ribbon.close_dropdown(); @@ -712,7 +714,10 @@ inventory::submit!(crate::command::CommandRegistration { #[cfg(test)] mod marquee_cancel_tests { - use crate::app::OpenCADStudio; + use crate::app::{GripPendingValue, OpenCADStudio}; + use crate::scene::model::object::GripMenuAction; + use crate::scene::pick::grip::GripEdit; + use acadrust::Handle; use iced::time::Instant; fn fresh() -> OpenCADStudio { @@ -826,4 +831,33 @@ mod marquee_cancel_tests { assert!(sel.box_crossing); assert!(sel.box_crossing_locked); } + + #[test] + fn starting_a_command_cancels_a_grip_value_edit() { + let mut app = fresh(); + let i = app.active_tab; + let handle = Handle::new(42); + app.tabs[i].active_grip = Some(GripEdit::radius( + handle, + 1, + glam::DVec3::new(2.0, 0.0, 0.0), + )); + app.grip_pending = Some(GripPendingValue { + handle, + grip_id: 1, + action: GripMenuAction::Radius, + label: "New radius", + }); + app.command_line.input = "5".to_string(); + + let _ = app.dispatch_command("LINE"); + + assert!(app.tabs[i].active_grip.is_none()); + assert!(app.grip_pending.is_none()); + assert!(app.command_line.input.is_empty()); + assert_eq!( + app.tabs[i].active_cmd.as_deref().map(|cmd| cmd.name()), + Some("LINE") + ); + } } From c50eb52320e6d87ffb3856d8dc6d1e6b025377a6 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 18:37:55 +0300 Subject: [PATCH 48/53] refactor(arcs): use kernel geometry for grip values --- src/entities/arc.rs | 89 +++++++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 19 deletions(-) diff --git a/src/entities/arc.rs b/src/entities/arc.rs index b9e0b1172..e4f35e9cb 100644 --- a/src/entities/arc.rs +++ b/src/entities/arc.rs @@ -364,38 +364,44 @@ impl crate::entities::traits::Grippable for Arc { action: crate::scene::model::object::GripMenuAction, point: glam::DVec3, ) -> Option { + use cadkernel::geom2d::{Circle as KernelCircle, Curve as KernelCurve, Vec2}; use crate::scene::model::object::GripMenuAction as A; if self.radius <= 1.0e-9 { return None; } - let (x, y, _) = crate::scene::view::transform::wcs_point_to_ocs( - (point.x, point.y, point.z), - (self.normal.x, self.normal.y, self.normal.z), - ); + let curve = crate::entities::curve::arc_curve(self); + let point = curve.plane.project(point.to_array())?; + let KernelCurve::Arc(arc) = &curve.curve else { + unreachable!("arc entity must produce an arc curve") + }; if matches!(action, A::Radius) && grip_id == 3 { - let radius = ((x - self.center.x).powi(2) + (y - self.center.y).powi(2)).sqrt(); + let radius = Vec2::from(point).distance(Vec2::from(arc.centre)); return (radius > 1.0e-9).then_some(radius); } if matches!(action, A::ArcLength) && grip_id == 3 { - let sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); - let middle_angle = self.start_angle + sweep * 0.5; - let middle_x = self.center.x + self.radius * middle_angle.cos(); - let middle_y = self.center.y + self.radius * middle_angle.sin(); - let tangent_x = -middle_angle.sin(); - let tangent_y = middle_angle.cos(); - let delta = (x - middle_x) * tangent_x + (y - middle_y) * tangent_y; - let length = self.radius * sweep + delta; - return (length > 1.0e-9 && length < self.radius * TAU - 1.0e-9) - .then_some(length); + let middle = Vec2::from(curve.curve.point_at(0.5)); + let tangent = Vec2::from(curve.curve.tangent_at(0.5)).normalize()?; + let length = curve.length() + (Vec2::from(point) - middle).dot(tangent); + let circumference = KernelCircle { + centre: arc.centre, + radius: arc.radius, + } + .length(); + return (length > 1.0e-9 && length < circumference - 1.0e-9).then_some(length); } if !matches!(action, A::Lengthen) { return None; } - let cursor_angle = (y - self.center.y).atan2(x - self.center.x); - let current_sweep = (self.end_angle - self.start_angle).rem_euclid(TAU); + let cursor_angle = KernelCurve::Circle(KernelCircle { + centre: arc.centre, + radius: arc.radius, + }) + .parameter_at(point) + * TAU; + let current_sweep = arc.sweep(); let desired_sweep = match grip_id { - 1 => (self.end_angle - cursor_angle).rem_euclid(TAU), - 2 => (cursor_angle - self.start_angle).rem_euclid(TAU), + 1 => (arc.end_angle - cursor_angle).rem_euclid(TAU), + 2 => (cursor_angle - arc.start_angle).rem_euclid(TAU), _ => return None, }; if desired_sweep <= 1.0e-9 { @@ -477,3 +483,48 @@ impl crate::entities::traits::MassPropsCalc for acadrust::entities::Arc { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::entities::traits::Grippable; + use crate::scene::model::object::GripMenuAction; + + #[test] + fn midpoint_grip_drives_radius_and_arc_length_in_entity_plane() { + let mut arc = Arc::default(); + arc.center = acadrust::types::Vector3::new(1.0, 2.0, 3.0); + arc.normal = acadrust::types::Vector3::new(0.0, 1.0, 0.0); + arc.radius = 2.0; + arc.start_angle = 0.0; + arc.end_angle = std::f64::consts::FRAC_PI_2; + + let curve = crate::entities::curve::arc_curve(&arc); + let middle_angle = std::f64::consts::FRAC_PI_4; + let radius_point = curve.plane.point_at([ + arc.center.x + 5.0 * middle_angle.cos(), + arc.center.y + 5.0 * middle_angle.sin(), + ]); + let radius = arc + .grip_menu_point_value(3, GripMenuAction::Radius, radius_point.into()) + .expect("radius value"); + assert!((radius - 5.0).abs() < 1.0e-9); + + let middle = glam::DVec3::from_array(curve.point_at(0.5)); + let tangent = glam::DVec3::from_array(curve.tangent_at(0.5)).normalize(); + let wanted_length = curve.length() + 1.25; + let arc_length = arc + .grip_menu_point_value( + 3, + GripMenuAction::ArcLength, + middle + tangent * 1.25, + ) + .expect("arc length value"); + assert!((arc_length - wanted_length).abs() < 1.0e-9); + + arc.apply_grip_menu_value(3, GripMenuAction::ArcLength, arc_length); + assert!( + (crate::entities::curve::arc_curve(&arc).length() - wanted_length).abs() < 1.0e-9 + ); + } +} From 14ef6c7bcd55c71f0ccf2c180a18ed2cc353ea57 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 19:16:30 +0300 Subject: [PATCH 49/53] fix(i18n): translate render mode choices --- src/app/view/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 3c33785d5..23ec26ce1 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -162,7 +162,7 @@ pub(super) struct RenderModeChoice(pub acadrust::entities::ViewportRenderMode); impl std::fmt::Display for RenderModeChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(crate::modules::view::visual_style::label_for(self.0)) + f.write_str(crate::t!(crate::modules::view::visual_style::label_for(self.0)).as_ref()) } } From c50427a8345d2f0d559d7e9a69cfd5070062dae4 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 21:48:37 +0300 Subject: [PATCH 50/53] i18n(tr): shorten shaded render mode labels --- locales/tr-TR/opencadstudio.ftl | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/locales/tr-TR/opencadstudio.ftl b/locales/tr-TR/opencadstudio.ftl index c1e06f91a..07441cb23 100644 --- a/locales/tr-TR/opencadstudio.ftl +++ b/locales/tr-TR/opencadstudio.ftl @@ -464,8 +464,8 @@ common = .fixed-height = Sabit Yükseklik: .fixed-length-dimfxl = Sabit uzunluk (DIMFXL) .fixed-length-ext-lines-dimfxlon = Sabit uzunluklu uzatma çizgileri (DIMFXLON) - .flat-shaded = Düz Gölgelendirilmiş - .flat-shaded-edges = Düz Gölgelendirilmiş + Kenarlar + .flat-shaded = Düz Gölge + .flat-shaded-edges = Düz Gölge + Kenarlar .flow-direction = Akış yönü: .font-load-failed-script-e = Yazı tipi yüklenemedi (__ocs_fmt_0__): __ocs_fmt_1__ .force-text-inside-dimtix = Metni içeride olmaya zorla (DIMTIX) @@ -479,8 +479,8 @@ common = .general = Genel .generate-tolerances-dimtol = Toleransları oluştur (DIMTOL) .geometry = Geometri - .gouraud-shaded = Gouraud Gölgelendirilmiş - .gouraud-shaded-edges = Gouraud Gölgelendirilmiş + Kenarlar + .gouraud-shaded = Gouraud Gölge + .gouraud-shaded-edges = Gouraud Gölge + Kenarlar .green = Yeşil .group = Grup .group-created = "__ocs_fmt_0__" grubu oluşturuldu. @@ -3498,7 +3498,7 @@ view = .syncpviewports-master-is-not-a-viewport = SYNCPVIEWPORTS: Ana nesne bir görünüm alanı değil. .syncpviewports-synced-n-viewport-s-to-the-mast = SYNCPVIEWPORTS: __ocs_fmt_0__ görünüm alanı ana görünüm alanıyla eşitlendi. .scale-arg-already-exists = __ocs_fmt_0__ ölçeği zaten var. - .shaded = Gölgelendirilmiş + .shaded = Gölge .sheet-set-manager = Pafta Seti Yöneticisi .shortcut-key-not-found = '__ocs_fmt_0__' kısayolu bulunamadı. .shortcut-key-removed = '__ocs_fmt_0__' kısayolu kaldırıldı. @@ -4430,7 +4430,7 @@ styles = .usage-lwdisplay-on-off = Kullanım: LWDISPLAY [ON|OFF] .usage-rename-type-old-new-types-layer-block-st = Kullanım: RENAME (türler: LAYER BLOCK STYLE DIMSTYLE LINETYPE UCS VIEW) .usage-scaletext-factor-or-scaletext-h-height = Kullanım: SCALETEXT veya SCALETEXT H - .vscurrent-visual-style-shaded-wireframe-hidden = VSCURRENT Görsel stil [Gölgelendirilmiş / Tel Kafes / Gizli / Gerçekçi / Kavramsal / X Işını]: + .vscurrent-visual-style-shaded-wireframe-hidden = VSCURRENT Görsel stil [Gölge / Tel Kafes / Gizli / Gerçekçi / Kavramsal / X Işını]: .windows-desktop = Windows masaüstü .big-font-file = büyük yazı tipi dosyası… .font-file-picker-lowercase = yazı tipi dosyası… From f4eeba75df47a036c5d2304de2cfbfde651d317e Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 10 Sep 2026 22:20:34 +0300 Subject: [PATCH 51/53] style(icons): refresh editing tool artwork --- assets/icons/copy.svg | 62 ++++++++++++++++++- assets/icons/erase.svg | 60 +++++++++++++++++- assets/icons/extend.svg | 22 +++---- assets/icons/hatch/hatch_lines.svg | 98 ++++++++++++++++++++++++++---- assets/icons/stretch.svg | 22 +++---- assets/icons/trim.svg | 36 +++++------ 6 files changed, 242 insertions(+), 58 deletions(-) diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg index 813f60544..f770b6cf6 100644 --- a/assets/icons/copy.svg +++ b/assets/icons/copy.svg @@ -1,4 +1,60 @@ - - - + + + + + + diff --git a/assets/icons/erase.svg b/assets/icons/erase.svg index b1ecb37c9..279591f69 100644 --- a/assets/icons/erase.svg +++ b/assets/icons/erase.svg @@ -1,4 +1,58 @@ - - - + + + + + + diff --git a/assets/icons/extend.svg b/assets/icons/extend.svg index 8180b9f10..eed8f3444 100644 --- a/assets/icons/extend.svg +++ b/assets/icons/extend.svg @@ -6,7 +6,7 @@ version="1.1" id="svg4" sodipodi:docname="extend.svg" - inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -23,9 +23,9 @@ inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" showgrid="true" - inkscape:zoom="11.265625" - inkscape:cx="31.955617" - inkscape:cy="29.869626" + inkscape:zoom="7.258968" + inkscape:cx="30.58286" + inkscape:cy="34.92232" inkscape:window-width="1920" inkscape:window-height="939" inkscape:window-x="0" @@ -49,27 +49,27 @@ + style="stroke-width:2.45268;stroke-dasharray:none" /> + style="stroke-width:2.3545;stroke-dasharray:none" /> - - - + + + + + + - - - - - - - + + + + + - + diff --git a/assets/icons/stretch.svg b/assets/icons/stretch.svg index b8bb66a58..2bdee9270 100644 --- a/assets/icons/stretch.svg +++ b/assets/icons/stretch.svg @@ -6,7 +6,7 @@ version="1.1" id="svg5" sodipodi:docname="stretch.svg" - inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -22,9 +22,9 @@ inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" - inkscape:zoom="12.762909" - inkscape:cx="33.926435" - inkscape:cy="28.010856" + inkscape:zoom="11.968488" + inkscape:cx="25.984903" + inkscape:cy="32.794451" inkscape:window-width="1920" inkscape:window-height="939" inkscape:window-x="0" @@ -48,12 +48,12 @@ visible="true" /> @@ -61,8 +61,8 @@ diff --git a/assets/icons/trim.svg b/assets/icons/trim.svg index abf2fb07f..7950e8884 100644 --- a/assets/icons/trim.svg +++ b/assets/icons/trim.svg @@ -6,7 +6,7 @@ version="1.1" id="svg6" sodipodi:docname="trim.svg" - inkscape:version="1.4.3 (0d15f75042, 2025-12-25)" + inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns="http://www.w3.org/2000/svg" @@ -22,9 +22,9 @@ inkscape:pageopacity="0" inkscape:pagecheckerboard="0" inkscape:deskcolor="#505050" - inkscape:zoom="11.265625" - inkscape:cx="31.955617" - inkscape:cy="32" + inkscape:zoom="6.1823588" + inkscape:cx="16.498557" + inkscape:cy="24.181709" inkscape:window-width="1920" inkscape:window-height="939" inkscape:window-x="0" @@ -32,32 +32,32 @@ inkscape:window-maximized="1" inkscape:current-layer="svg6" /> + style="stroke-width:2.62057;stroke-dasharray:none" /> + style="stroke-width:2.62057;stroke-dasharray:none" /> From 16e52dcd2096060df571799dd4a0ff4ca0e1159c Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:27:56 +0300 Subject: [PATCH 52/53] Align and compact Draw popup with accent border --- src/ui/ribbon/draw_panel.rs | 37 ++++++++++++++++++++++++++----------- src/ui/ribbon/mod.rs | 4 ++-- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 4e8e78614..3766e3f37 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -13,8 +13,11 @@ use crate::t; use crate::ui::{icons, wrap_bar::PosReport}; const PANEL_ID: &str = "draw_extension"; -const CELL: f32 = 36.0; -const GAP: f32 = 3.0; +const GROUP_ID: &str = "draw_extension_group"; +const SCALE: f32 = 0.7; +const CELL: f32 = 36.0 * SCALE; +const GAP: f32 = 3.0 * SCALE; +const OPTION_HEIGHT: f32 = 30.0 * SCALE; struct Tool { command: &'static str, @@ -29,6 +32,14 @@ pub(super) fn owns_dropdown(id: &str) -> bool { id == PANEL_ID || TOOLS.iter().any(|tool| tool.command == id && !tool.options.is_empty()) } +pub(super) fn group_anchor<'a>(title: &str, content: Element<'a, Message>) -> Element<'a, Message> { + if title == "Draw" { + PosReport::new(GROUP_ID, content).into() + } else { + content + } +} + pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Element<'a, Message> { if title != "Draw" || TOOLS.is_empty() { return container(text(t!(title)).size(9).style(muted_text_style)).padding([1, 4]).into(); @@ -42,20 +53,20 @@ pub(super) fn group_title<'a>(title: &'static str, open: &Option) -> Ele } fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { - let face = button(make_icon(IconKind::Svg(tool.icon), 23.0)) + let face = button(make_icon(IconKind::Svg(tool.icon), 23.0 * SCALE)) .on_press(Message::DropdownSelectItem { dropdown_id: PANEL_ID, cmd: tool.command }) .style(move |theme: &Theme, status| tool_btn_style(theme, active, status)) - .width(if tool.options.is_empty() { CELL } else { CELL - 11.0 }) + .width(if tool.options.is_empty() { CELL } else { CELL - 11.0 * SCALE }) .height(CELL) - .padding(3); + .padding(3.0 * SCALE); let tip = format!("{}\n{} {}", t!(tool.label), t!("Command:"), tool.command); let face: Element<'static, Message> = tooltip(face, make_tip(tip), tooltip::Position::Bottom) .delay(Duration::from_millis(400)).style(tip_style).into(); if !tool.options.is_empty() { - row![face, button(icons::themed_arrow_down(6.0)) + row![face, button(icons::themed_arrow_down(6.0 * SCALE)) .on_press(Message::ToggleRibbonDropdown(tool.command.to_string())) .style(move |theme: &Theme, status| tool_btn_style(theme, false, status)) - .width(11).height(CELL).padding(2)].into() + .width(11.0 * SCALE).height(CELL).padding(2.0 * SCALE)].into() } else { face } @@ -64,7 +75,7 @@ fn tool_button(tool: &Tool, active: bool) -> Element<'static, Message> { pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element<'a, Message> { let width = (7.0 * (CELL + GAP) - GAP + 12.0).min((win.0 - 8.0).max(CELL + 12.0)); let (_, x, anchor_top) = ribbon.dd_anchor(PANEL_ID, width, win.0); - let anchor = crate::ui::wrap_bar::dropdown_bounds(PANEL_ID); + let anchor = crate::ui::wrap_bar::dropdown_bounds(GROUP_ID); let left = anchor.map_or(x, |b| b.x).clamp(0.0, (win.0 - width).max(0.0)); let top = anchor_top.min((win.1 - 80.0).max(0.0)); let available_height = (win.1 - top - 4.0).max(1.0); @@ -72,13 +83,13 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element let option_tool = TOOLS.iter().find(|tool| tool.command == id && !tool.options.is_empty()); let content_height = option_tool.map_or_else( || TOOLS.len().div_ceil(cols) as f32 * (CELL + GAP) - GAP + 12.0, - |tool| tool.options.len() as f32 * 30.0, + |tool| tool.options.len() as f32 * OPTION_HEIGHT, ); let contents: Element<'static, Message> = if let Some(tool) = option_tool { column(tool.options.iter().map(|&(cmd, label)| { button(text(t!(label)).size(11)) .on_press(Message::DropdownSelectItem { dropdown_id: tool.command, cmd }) - .style(popup_row_style).width(Fill).height(30).padding([6, 10]).into() + .style(popup_row_style).width(Fill).height(OPTION_HEIGHT).padding([3, 7]).into() }).collect::>>()).into() } else { column(TOOLS.chunks(cols).map(|tools| { @@ -87,6 +98,10 @@ pub(super) fn overlay<'a>(ribbon: &Ribbon, id: &str, win: (f32, f32)) -> Element }).collect::>>()).spacing(GAP).padding(6).into() }; let panel = container(scrollable(contents).height(content_height.min(available_height))) - .width(width).style(popup_panel_style); + .width(width).style(|theme: &Theme| { + let mut style = popup_panel_style(theme); + style.border.color = theme.palette().primary.base.color; + style + }); dropdown_backdrop(position_ribbon_dropdown(panel.into(), false, left, top)) } diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index e0f2b2c49..7ffc5ec7b 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -1370,7 +1370,7 @@ fn render_group<'a>( r.push(e) }); - column![ + draw_panel::group_anchor(group.title, column![ tools_el, draw_panel::group_title(group.title, open_dd), ] @@ -1378,7 +1378,7 @@ fn render_group<'a>( .spacing(0) .padding([3u16, 4]) .height(Length::Fixed(TOOL_BAR_H)) - .into() + .into()) } /// The top-level command id of a ribbon item, if it has one. From f77c337af4602469054e1892c0165186e730cf52 Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Thu, 10 Sep 2026 20:01:55 +0100 Subject: [PATCH 53/53] feat(command): add MTP/M2P midpoint-between-two-points modifier Add MTP (Midpoint Between Two Points) as a keyword that can be typed during any point-picking command (e.g. LINE) to suspend the current command, prompt for two points, and return the midpoint to the parent. - New Mid2PointCommand registered under both "MTP" and "M2P" aliases - ReturnPoint CmdResult variant feeds the resolved midpoint back to the suspended parent command - MTP/M2P typed during a point step suspends the active command and starts the midpoint modifier; Escape restores the parent command - Preview wires show a rubber-band line with a triangle marker at the midpoint - Character-by-character typing is routed correctly even with dynamic input active (digits no longer leak into dyn fields) - Three automation tests cover the full workflow, ESC restoration, and dynamic-input character routing --- src/app/automation.rs | 97 +++++++++++++++++++++++++++++ src/app/command_driver.rs | 88 ++++++++++++++++++++++++--- src/app/update/command.rs | 30 ++++++++- src/command.rs | 124 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 330 insertions(+), 9 deletions(-) diff --git a/src/app/automation.rs b/src/app/automation.rs index c02cc80a9..b6038532a 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -1513,4 +1513,101 @@ mod tests { // Finish let _ = app.update(Message::CommandOptionPick(String::new())); } + + #[test] + fn test_mtp_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().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")); + + // Type M2P + let _ = app.update(Message::CommandInput("M2P".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + assert!(app.tabs[0].suspended_cmd.is_some()); + assert_eq!(app.tabs[0].suspended_cmd.as_ref().map(|c| c.name()), Some("LINE")); + + // Point 1: 0,0 + let _ = app.update(Message::CommandInput("0,0".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + + // Point 2: 10,20 + let _ = app.update(Message::CommandInput("10,20".to_string())); + let _ = app.update(Message::CommandSubmit); + + // MTP should have finished and restored LINE, with midpoint (5, 10, 0) + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert!(app.tabs[0].suspended_cmd.is_none()); + assert_eq!(app.last_point, Some(glam::DVec3::new(5.0, 10.0, 0.0))); + + // Cancel LINE + let _ = app.update(Message::CommandEscape); + assert!(app.tabs[0].active_cmd.is_none()); + } + + #[test] + fn test_mtp_escape_restores_parent() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + + // 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")); + + // Type MTP + let _ = app.update(Message::CommandInput("MTP".to_string())); + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + + // Escape during MTP + let _ = app.update(Message::CommandEscape); + // Parent LINE must be restored, not cancelled! + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("LINE")); + assert!(app.tabs[0].suspended_cmd.is_none()); + + // Escape again cancels LINE + let _ = app.update(Message::CommandEscape); + assert!(app.tabs[0].active_cmd.is_none()); + } + + #[test] + fn test_mtp_typing_routing_with_dyn_input() { + use crate::app::Message; + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + app.dyn_input = true; + + // 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")); + + // Simulate typing 'm', '2', 'p' character by character + let _ = app.update(Message::CommandAppendChar("m".to_string())); + assert_eq!(app.command_line.input, "M"); + + // The digit '2' must stay in command_line.input instead of routing to dyn fields! + let _ = app.update(Message::CommandAppendChar("2".to_string())); + assert_eq!(app.command_line.input, "M2"); + + let _ = app.update(Message::CommandAppendChar("p".to_string())); + assert_eq!(app.command_line.input, "M2P"); + + // Submit triggers MTP + let _ = app.update(Message::CommandSubmit); + assert_eq!(app.tabs[0].active_cmd.as_ref().map(|c| c.name()), Some("MTP")); + } } + diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 1f1156798..87635b15f 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -671,6 +671,29 @@ impl OpenCADStudio { } return Task::none(); } + 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 { + self.start_mtp_modifier(i); + return Task::none(); + } + } if let Some((coord, kind)) = super::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- @@ -712,7 +735,22 @@ impl OpenCADStudio { /// pick-first selector relaunching MOVE on the picked set works this way). /// Pure-selection commands (SELECTALL, QSELECT, …) run without an active /// command, so `was_active` is false and their selection is preserved. - pub(super) fn apply_cmd_result(&mut self, result: CmdResult) -> Task { + pub(super) fn apply_cmd_result(&mut self, mut result: CmdResult) -> Task { + let i = self.active_tab; + if let CmdResult::ReturnPoint(pt) = result { + self.tabs[i].scene.clear_preview_wire(); + if let Some(mut suspended) = self.tabs[i].suspended_cmd.take() { + self.last_point = Some(pt); + let next_res = suspended.on_point(pt); + self.tabs[i].active_cmd = Some(suspended); + result = next_res; + } else { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + return Task::none(); + } + } let settings = self.tabs[self.active_tab] .active_cmd .as_ref() @@ -877,6 +915,18 @@ impl OpenCADStudio { TableCellEditStart::Started } + pub(in crate::app) fn start_mtp_modifier(&mut self, i: usize) { + let parent = self.tabs[i].active_cmd.take(); + self.tabs[i].suspended_cmd = parent; + self.tabs[i].scene.clear_preview_wire(); + let cmd = crate::command::Mid2PointCommand::new(); + let prompt = crate::command::CadCommand::prompt(&cmd); + self.tabs[i].active_cmd = Some(Box::new(cmd)); + self.command_line.push_info(&prompt); + self.command_line.set_step_options(Vec::new()); + self.refresh_active_cmd_preview(i); + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; let preserve_commit_layer = self.tabs[i] @@ -2439,14 +2489,30 @@ impl OpenCADStudio { cancel @ (CmdResult::Cancel | CmdResult::CancelForSpaceChange) => { let space_changed = matches!(cancel, CmdResult::CancelForSpaceChange); self.tabs[i].scene.clear_preview_wire(); - self.tabs[i].active_cmd = None; - self.tabs[i].snap_result = None; - self.restore_pre_cmd_tangent(); - self.command_line.push_info(crate::t!(if space_changed { - "Command cancelled because the active drawing space changed." + if !space_changed && self.tabs[i].suspended_cmd.is_some() { + self.tabs[i].active_cmd = self.tabs[i].suspended_cmd.take(); + let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); + if let Some(p) = prompt { + self.command_line.push_info(&p); + } + let opts = self.tabs[i] + .active_cmd + .as_ref() + .map(|c| c.options()) + .unwrap_or_default(); + self.command_line.set_step_options(opts); + self.refresh_active_cmd_preview(i); } else { - "Command cancelled." - }).as_ref()); + self.tabs[i].suspended_cmd = None; + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.restore_pre_cmd_tangent(); + self.command_line.push_info(crate::t!(if space_changed { + "Command cancelled because the active drawing space changed." + } else { + "Command cancelled." + }).as_ref()); + } } CmdResult::SelectByPath { path, @@ -4751,6 +4817,12 @@ impl OpenCADStudio { self.tabs[i].scene.clear_preview_wire(); self.restore_pre_cmd_tangent(); } + CmdResult::ReturnPoint(_) => { + self.tabs[i].active_cmd = None; + self.tabs[i].snap_result = None; + self.tabs[i].scene.clear_preview_wire(); + self.restore_pre_cmd_tangent(); + } } // When no command is running the ribbon tool button still has to // visually deactivate. Keyboard focus is assigned below to whichever diff --git a/src/app/update/command.rs b/src/app/update/command.rs index b6ec4808f..23df084a7 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -191,7 +191,11 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { c.is_ascii_digit() || matches!(c, '.' | '-' | '+' | '*' | '/' | '^' | '%' | '(' | ')') }); - if dyn_field_char && self.dyn_input && !self.tabs[i].dyn_fields.is_empty() { + if dyn_field_char + && self.command_line.input.is_empty() + && self.dyn_input + && !self.tabs[i].dyn_fields.is_empty() + { let a = self.tabs[i] .dyn_active .min(self.tabs[i].dyn_fields.len() - 1); @@ -634,6 +638,30 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { .map(|c| c.is_free_text_step()) .unwrap_or(false); let raw = self.command_line.input.trim().to_string(); + 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 { + self.command_line.input.clear(); + self.start_mtp_modifier(i); + return self.focus_cmd_input(); + } + } let text = if free_text { raw } else { diff --git a/src/command.rs b/src/command.rs index e45da43de..d69859462 100644 --- a/src/command.rs +++ b/src/command.rs @@ -744,6 +744,88 @@ impl CadCommand for TwoValuePromptCommand { } } +// ── Mid between 2 points (MTP / M2P) ──────────────────────────────────────── + +/// Point-entry modifier that prompts for two points, draws a preview connecting line +/// with a midpoint marker, and returns the midpoint to the caller. +#[derive(Debug, Default)] +pub struct Mid2PointCommand { + pub first_point: Option, +} + +impl Mid2PointCommand { + pub fn new() -> Self { + Self { first_point: None } + } +} + +impl CadCommand for Mid2PointCommand { + fn name(&self) -> &'static str { + "MTP" + } + + fn prompt(&self) -> String { + if self.first_point.is_none() { + crate::t!("_mtp Specify first point of mid:").into_owned() + } else { + crate::t!("_mtp Specify second point of mid:").into_owned() + } + } + + fn on_point(&mut self, pt: DVec3) -> CmdResult { + if let Some(first) = self.first_point { + let mid = (first + pt) * 0.5; + CmdResult::ReturnPoint(mid) + } else { + self.first_point = Some(pt); + CmdResult::NeedPoint + } + } + + fn on_enter(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_escape(&mut self) -> CmdResult { + CmdResult::Cancel + } + + fn on_preview_wires(&mut self, pt: DVec3) -> Vec { + let mut wires = Vec::new(); + if let Some(first) = self.first_point { + // Rubber-band line connecting the first point to the cursor + wires.push(WireModel::solid_f64( + "mtp_rubber_band".to_string(), + vec![[first.x, first.y, first.z], [pt.x, pt.y, pt.z]], + WireModel::CYAN, + false, + )); + // Midpoint marker (AutoCAD style triangle glyph) + let mid = (first + pt) * 0.5; + let dist = (pt - first).length(); + let s = (dist * 0.02).clamp(0.5, 10.0); + let h = s * 1.5; + let w = s * 1.0; + wires.push(WireModel::solid_f64( + "mtp_mid_triangle".to_string(), + vec![ + [mid.x, mid.y + h * (2.0 / 3.0), mid.z], + [mid.x - w, mid.y - h * (1.0 / 3.0), mid.z], + [mid.x + w, mid.y - h * (1.0 / 3.0), mid.z], + ], + WireModel::CYAN, + true, + )); + } + wires + } +} + +inventory::submit!(CommandRegistration { + names: &["MTP", "M2P"], +}); + + /// Generic interactive front-end for a keyword command that operates on the /// current selection (CHPROP, ADJUST, XDATA, UNDERLAY, DRAWORDER…). If nothing /// is selected when it starts it first gathers a selection (Enter confirms), @@ -1629,6 +1711,8 @@ pub enum CmdResult { UndoDocument, /// Sets the TEXTEDITMODE system variable and ends the command. SetTexteditMode(bool), + /// Return a resolved point back to a suspended parent command (e.g. MTP / M2P). + ReturnPoint(DVec3), } /// What kind of value the active command is currently asking for. Drives @@ -2385,3 +2469,43 @@ pub fn all_registered_command_names() -> Vec<&'static str> { .flat_map(|r| r.names.iter().copied()) .collect() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mid2point_command() { + let mut cmd = Mid2PointCommand::new(); + assert_eq!(cmd.name(), "MTP"); + assert_eq!(cmd.prompt(), "_mtp Specify first point of mid:"); + + // First point + let res = cmd.on_point(DVec3::new(10.0, 20.0, 0.0)); + assert!(matches!(res, CmdResult::NeedPoint)); + assert_eq!(cmd.prompt(), "_mtp Specify second point of mid:"); + + // Preview wires + let wires = cmd.on_preview_wires(DVec3::new(30.0, 40.0, 0.0)); + assert!(!wires.is_empty()); + + // Second point + let res = cmd.on_point(DVec3::new(30.0, 40.0, 0.0)); + assert!(matches!(res, CmdResult::ReturnPoint(mid) if mid == DVec3::new(20.0, 30.0, 0.0))); + } + + #[test] + fn test_mid2point_cancel() { + let mut cmd = Mid2PointCommand::new(); + assert!(matches!(cmd.on_escape(), CmdResult::Cancel)); + assert!(matches!(cmd.on_enter(), CmdResult::Cancel)); + } + + #[test] + fn test_mtp_registered() { + let names = all_registered_command_names(); + assert!(names.contains(&"MTP")); + assert!(names.contains(&"M2P")); + } +} +