forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into codex/sheet-set-manager
# Conflicts: # Cargo.lock # src/app/control/actions.rs # src/ui/window/field_dialog.rs
This commit is contained in:
commit
1696316e1f
95 files changed
+4269
-722
No files matched your search
+44
-1
@@ -1398,7 +1398,13 @@ impl OpenCADStudio {
|
||||
// The same snapshot a normal save writes: display-only overrides and
|
||||
// resolved xref content stay out of the file.
|
||||
let snapshot = self.tabs[i].scene.document_for_save();
|
||||
let before = document_manifest(&snapshot);
|
||||
// Bake simulation: save_as_version mints *D geometry blocks on its
|
||||
// private clone, so the reopened manifest always contains baked
|
||||
// sub-entities. Compare against the same baked view or every new
|
||||
// (still blockless) dimension trips a false semantic_mismatch.
|
||||
let mut baked_view = snapshot.clone();
|
||||
crate::modules::draw::modify::explode::bake_dimension_blocks(&mut baked_view);
|
||||
let before = document_manifest(&baked_view);
|
||||
crate::io::save_as_version(&snapshot, &path, version)
|
||||
.map_err(|error| json!({
|
||||
"ok":false,"status":"failed","code":"save_failed","error":error,
|
||||
@@ -1572,6 +1578,43 @@ mod tests {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_verified_accepts_new_dimension_with_baked_block() {
|
||||
use codec::entities::{Dimension, DimensionLinear};
|
||||
use codec::{EntityType, Vector3};
|
||||
|
||||
let mut app = super::OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
let i = app.active_tab;
|
||||
let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0));
|
||||
d.definition_point = Vector3::new(0.0, 5.0, 0.0);
|
||||
d.base.text_middle_point = Vector3::new(5.0, 5.0, 0.0);
|
||||
app.tabs[i]
|
||||
.scene
|
||||
.document
|
||||
.add_entity(EntityType::Dimension(Dimension::Linear(d)))
|
||||
.unwrap();
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ocs_verified_dim_{}_{}.dwg",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos(),
|
||||
));
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let result = app
|
||||
.save_verified_request(&serde_json::json!({
|
||||
"path":path,
|
||||
"target_format":"dwg",
|
||||
"overwrite":true,
|
||||
}))
|
||||
.expect("verified save of a new dimension");
|
||||
assert_eq!(result["verified"], true, "{result}");
|
||||
assert!(path.is_file());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_target_version_parser_never_silently_defaults() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -170,11 +170,20 @@ impl OpenCADStudio {
|
||||
}
|
||||
let label = self.history_label_from_active_cmd(i, "TRIM");
|
||||
self.push_undo_snapshot(i, label);
|
||||
self.tabs[i].scene.erase_entities(&[handle]);
|
||||
// A replacement reusing the original handle must land after the erase;
|
||||
// fresh pieces take over its hatch associations before it goes.
|
||||
let reuses_handle = new_entities.iter().any(|e| e.common().handle == handle);
|
||||
if reuses_handle {
|
||||
self.tabs[i].scene.erase_entities(&[handle]);
|
||||
}
|
||||
let new_handles: Vec<codec::Handle> = new_entities
|
||||
.into_iter()
|
||||
.map(|e| self.tabs[i].scene.add_entity(e))
|
||||
.collect();
|
||||
if !reuses_handle {
|
||||
self.tabs[i].scene.split_hatch_source(handle, &new_handles);
|
||||
self.tabs[i].scene.erase_entities(&[handle]);
|
||||
}
|
||||
// Rebuild replaced dimensions from edited data.
|
||||
for &nh in &new_handles {
|
||||
if matches!(
|
||||
|
||||
@@ -200,6 +200,7 @@ impl OpenCADStudio {
|
||||
pub(super) fn handle_commit_and_exit(
|
||||
&mut self,
|
||||
entity: codec::EntityType,
|
||||
preserve_commit_layer: bool,
|
||||
) -> Option<Task<Message>> {
|
||||
let i = self.active_tab;
|
||||
// XATTACH: the definition and its INSERT are created together
|
||||
@@ -231,7 +232,15 @@ impl OpenCADStudio {
|
||||
// objects are not part of the add delta, so the step is a snapshot.
|
||||
let text_field = self.tabs[i].active_cmd.as_ref().and_then(|cmd| cmd.text_field());
|
||||
let label = self.history_label_from_active_cmd(i, "ENTITY");
|
||||
let delta_safe = text_field.is_none() && self.delta_add_safe(i, &entity);
|
||||
// A block reference whose attribute definitions carry fields gets
|
||||
// them copied onto its attributes (field objects: snapshot step).
|
||||
let attribute_fields = match &entity {
|
||||
codec::EntityType::Insert(insert) if !insert.attributes.is_empty() => {
|
||||
crate::entities::field::block_has_attribute_fields(&self.tabs[i].scene.document, &insert.block_name)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
let delta_safe = text_field.is_none() && !attribute_fields && self.delta_add_safe(i, &entity);
|
||||
let field_value = match &entity {
|
||||
codec::EntityType::MText(m) => m.value.clone(),
|
||||
codec::EntityType::AttributeDefinition(a) => a.default_value.clone(),
|
||||
@@ -247,7 +256,11 @@ impl OpenCADStudio {
|
||||
);
|
||||
let association_enabled =
|
||||
self.tabs[i].scene.document.header.dimension_associativity == 2;
|
||||
let committed = self.commit_entity_handle(entity);
|
||||
let committed = if preserve_commit_layer {
|
||||
self.commit_entity_handle_preserve_layer(entity)
|
||||
} else {
|
||||
self.commit_entity_handle(entity)
|
||||
};
|
||||
if is_associative_dimension && association_enabled {
|
||||
if let Some(handle) = committed {
|
||||
let sources = self.infer_dimension_sources_guarded(i, handle);
|
||||
@@ -264,6 +277,10 @@ impl OpenCADStudio {
|
||||
&field_value,
|
||||
);
|
||||
}
|
||||
if let (Some(handle), true) = (committed, attribute_fields) {
|
||||
crate::entities::field::attach_attribute_fields(&mut self.tabs[i].scene.document, handle);
|
||||
self.tabs[i].scene.bump_entities(&[(handle, crate::scene::ChangeKind::Modified)]);
|
||||
}
|
||||
if let Some(handle) = committed {
|
||||
self.apply_continuous_constraints(i, &[handle]);
|
||||
}
|
||||
@@ -972,7 +989,7 @@ impl OpenCADStudio {
|
||||
let i = self.active_tab;
|
||||
// Collect the full AttributeDefinitions owned by this block
|
||||
// record so each created attribute keeps its geometry (#255).
|
||||
let attdefs: Vec<codec::entities::AttributeDefinition> = {
|
||||
let mut attdefs: Vec<codec::entities::AttributeDefinition> = {
|
||||
let doc = &self.tabs[i].scene.document;
|
||||
if let Some(br) = doc.block_records.get(&block_name) {
|
||||
br.entity_handles
|
||||
@@ -992,6 +1009,12 @@ impl OpenCADStudio {
|
||||
}
|
||||
};
|
||||
|
||||
// ATTREQ 0: attributes take their defaults without prompting.
|
||||
if !self.tabs[i].scene.document.header.attribute_request {
|
||||
for attdef in &mut attdefs {
|
||||
attdef.flags.preset = true;
|
||||
}
|
||||
}
|
||||
if attdefs.is_empty() {
|
||||
// No attribute definitions — commit the INSERT directly.
|
||||
let entity = self.tabs[i]
|
||||
|
||||
@@ -381,8 +381,7 @@ impl OpenCADStudio {
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.map(|entity| entity.common().owner_handle);
|
||||
self.tabs[tab].scene.erase_entities(&[handle]);
|
||||
entities
|
||||
let pieces: Vec<Handle> = entities
|
||||
.into_iter()
|
||||
.map(|mut entity| {
|
||||
entity.common_mut().handle = Handle::NULL;
|
||||
@@ -391,7 +390,11 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.tabs[tab].scene.add_entity(entity)
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
// Hand the hatch associations over before the original goes.
|
||||
self.tabs[tab].scene.split_hatch_source(handle, &pieces);
|
||||
self.tabs[tab].scene.erase_entities(&[handle]);
|
||||
pieces
|
||||
};
|
||||
for &updated in &handles {
|
||||
if matches!(
|
||||
@@ -507,7 +510,7 @@ impl OpenCADStudio {
|
||||
self.handle_copy_to_clipboard(handles, base);
|
||||
}
|
||||
CmdResult::CommitAndExit(entity) => {
|
||||
if let Some(task) = self.handle_commit_and_exit(entity) {
|
||||
if let Some(task) = self.handle_commit_and_exit(entity, preserve_commit_layer) {
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1268,7 +1268,8 @@ impl OpenCADStudio {
|
||||
})
|
||||
.collect();
|
||||
// Pick-first (#422): with offsettable objects already selected,
|
||||
// skip the pick step and go straight to distance / side.
|
||||
// skip the distance and pick steps and open on the live side
|
||||
// preview.
|
||||
let preselected: Vec<_> = self.tabs[i]
|
||||
.scene
|
||||
.selected_entities()
|
||||
|
||||
+118
-2
@@ -3,7 +3,7 @@
|
||||
|
||||
use crate::app::{Message, OpenCADStudio};
|
||||
use crate::command::CadCommand;
|
||||
use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand};
|
||||
use crate::modules::annotate::field_cmd::{FieldObjectPickCommand, FieldPlaceCommand, FieldTablePickCommand};
|
||||
use crate::ui::window::field_dialog::{
|
||||
fields_of, object_properties, FieldDialogMsg, FieldDialogState, FieldTarget, DATE_FORMATS,
|
||||
NAMED_TYPES,
|
||||
@@ -35,13 +35,70 @@ impl OpenCADStudio {
|
||||
}
|
||||
Some(Task::none())
|
||||
}
|
||||
cmd if cmd == "_FIELD_CELL" || cmd.starts_with("_FIELD_CELL ") => {
|
||||
let parts: Vec<&str> = cmd.split_whitespace().skip(1).collect();
|
||||
if let Some(expression) = self.table_cell_expression(i, &parts) {
|
||||
if let Some(state) = self.field_dialog.as_mut() {
|
||||
// The reference appends at the end, with no operator.
|
||||
state.formula.push_str(&expression);
|
||||
}
|
||||
self.refresh_field_preview();
|
||||
// A real result gets the Decimal format, as the reference picks it.
|
||||
if let Some(state) = self.field_dialog.as_mut() {
|
||||
if state.formula_format == 0 && state.preview.contains('.') {
|
||||
state.formula_format = 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.field_dialog.is_some() {
|
||||
self.active_modal = Some(crate::app::ModalKind::Field);
|
||||
self.refresh_field_preview();
|
||||
}
|
||||
Some(Task::none())
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The formula text for a picked table cell or cell range:
|
||||
/// `Table(%<\_ObjId H>%).B4` or `Table(%<\_ObjId H>%).Evaluate(Sum(A3:B5))`.
|
||||
fn table_cell_expression(&self, i: usize, parts: &[&str]) -> Option<String> {
|
||||
let function = *parts.first()?;
|
||||
let cell = |at: &[&str]| -> Option<(codec::Handle, usize, usize)> {
|
||||
let handle = codec::Handle::new(u64::from_str_radix(at.first()?, 16).ok()?);
|
||||
let point = glam::DVec3::new(at.get(1)?.parse().ok()?, at.get(2)?.parse().ok()?, at.get(3)?.parse().ok()?);
|
||||
let document = &self.tabs[i].scene.document;
|
||||
let codec::EntityType::Table(table) = document.get_entity(handle)? else {
|
||||
return None;
|
||||
};
|
||||
let hit = crate::modules::annotate::table_cmd::table_cell_at(table, None, point)?;
|
||||
Some((handle, hit.row, hit.column))
|
||||
};
|
||||
let name = |row: usize, column: usize| format!("{}{}", column_letters(column), row + 1);
|
||||
let (table, row, column) = cell(parts.get(1..5)?)?;
|
||||
if function == "Cell" {
|
||||
return Some(format!("Table(%<\\_ObjId {:X}>%).{}", table.value(), name(row, column)));
|
||||
}
|
||||
let (second_table, row2, column2) = cell(parts.get(5..9)?)?;
|
||||
if second_table != table {
|
||||
return None;
|
||||
}
|
||||
Some(format!(
|
||||
"Table(%<\\_ObjId {:X}>%).Evaluate({function}({}:{}))",
|
||||
table.value(),
|
||||
name(row.min(row2), column.min(column2)),
|
||||
name(row.max(row2), column.max(column2)),
|
||||
))
|
||||
}
|
||||
|
||||
/// Open the Field dialog for `target`.
|
||||
pub(in crate::app) fn open_field_dialog(&mut self, target: FieldTarget) {
|
||||
let mut state = FieldDialogState::new(target);
|
||||
if !matches!(target, FieldTarget::NewText) {
|
||||
state.placeholder_block = self.tabs[self.active_tab]
|
||||
.active_block_edit_session()
|
||||
.map(|session| session.block_name.clone());
|
||||
}
|
||||
state.examples = DATE_FORMATS
|
||||
.iter()
|
||||
.map(|f| self.field_value(&format!("\\AcVar Date \\f \"{f}\""), &[]))
|
||||
@@ -69,7 +126,17 @@ impl OpenCADStudio {
|
||||
return;
|
||||
};
|
||||
let (code, objects) = state.code();
|
||||
let preview = if state.complete() { self.field_value(&code, &objects) } else { String::new() };
|
||||
let preview = if state.complete() {
|
||||
if state.name == "BlockPlaceholder" {
|
||||
// A placeholder shows its property name until a block reference resolves it.
|
||||
let (label, _, _) = codec::fields::BLOCK_PLACEHOLDER_PROPERTIES[state.placeholder_property];
|
||||
text_case(label, state.text_case)
|
||||
} else {
|
||||
self.field_value(&code, &objects)
|
||||
}
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
if let Some(state) = self.field_dialog.as_mut() {
|
||||
state.preview = preview;
|
||||
}
|
||||
@@ -170,6 +237,18 @@ impl OpenCADStudio {
|
||||
);
|
||||
}
|
||||
FieldDialogMsg::PlotScale(k) => state.plot_scale = k,
|
||||
FieldDialogMsg::PlaceholderProperty(k) => {
|
||||
state.placeholder_property = k;
|
||||
state.text_case = 0;
|
||||
}
|
||||
FieldDialogMsg::TableFunction(function) => {
|
||||
// The dialog waits while the cells are picked.
|
||||
self.active_modal = None;
|
||||
let command = FieldTablePickCommand::new(function);
|
||||
self.command_line.push_info(&command.prompt());
|
||||
self.tabs[i].active_cmd = Some(Box::new(command));
|
||||
return Task::none();
|
||||
}
|
||||
FieldDialogMsg::SelectObject => {
|
||||
// The dialog waits while one object is picked.
|
||||
self.active_modal = None;
|
||||
@@ -241,3 +320,40 @@ impl OpenCADStudio {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// Spreadsheet column letters: A … Z, AA, AB …
|
||||
fn column_letters(mut column: usize) -> String {
|
||||
let mut letters = Vec::new();
|
||||
loop {
|
||||
letters.push(b'A' + (column % 26) as u8);
|
||||
if column < 26 {
|
||||
break;
|
||||
}
|
||||
column = column / 26 - 1;
|
||||
}
|
||||
letters.reverse();
|
||||
String::from_utf8(letters).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The Field dialog's text cases: (none), Uppercase, Lowercase, First capital, Title case.
|
||||
fn text_case(value: &str, case: usize) -> String {
|
||||
match case {
|
||||
1 => value.to_uppercase(),
|
||||
2 => value.to_lowercase(),
|
||||
3 => {
|
||||
let lower = value.to_lowercase();
|
||||
let mut chars = lower.chars();
|
||||
chars.next().map(|c| c.to_uppercase().chain(chars).collect()).unwrap_or_default()
|
||||
}
|
||||
4 => value
|
||||
.split(' ')
|
||||
.map(|word| {
|
||||
let lower = word.to_lowercase();
|
||||
let mut chars = lower.chars();
|
||||
chars.next().map(|c| c.to_uppercase().chain(chars).collect::<String>()).unwrap_or_default()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
_ => value.to_string(),
|
||||
}
|
||||
}
|
||||
+71
-2
@@ -93,7 +93,26 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
pub(super) fn dispatch_command(&mut self, cmd: &str) -> Task<Message> {
|
||||
self.dispatch_command_inner(cmd, false)
|
||||
let task = self.dispatch_command_inner(cmd, false);
|
||||
self.clear_idle_tool_highlight();
|
||||
task
|
||||
}
|
||||
|
||||
/// Turn the ribbon highlight off when the command just dispatched left
|
||||
/// nothing running: no interactive command, dialog or navigation mode.
|
||||
/// One-shot commands (view changes, clipboard, toggles, audits…) would
|
||||
/// otherwise leave it lit forever; interactive commands and dialog owners
|
||||
/// keep theirs until the command end / modal close clears it. (#355)
|
||||
pub(in crate::app) fn clear_idle_tool_highlight(&mut self) {
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_none()
|
||||
&& self.active_modal.is_none()
|
||||
&& !self.tabs[i].pan_mode
|
||||
&& !self.tabs[i].orbit_mode
|
||||
&& !self.tabs[i].zoom_dynamic_mode
|
||||
{
|
||||
self.ribbon.deactivate_tool();
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch a verb typed at the interactive command line, falling back to
|
||||
@@ -105,7 +124,9 @@ impl OpenCADStudio {
|
||||
/// callers (ribbon, plugins, headless automation) use `dispatch_command`
|
||||
/// and never get silent substitution.
|
||||
pub(super) fn dispatch_command_or_suggest(&mut self, cmd: &str) -> Task<Message> {
|
||||
self.dispatch_command_inner(cmd, true)
|
||||
let task = self.dispatch_command_inner(cmd, true);
|
||||
self.clear_idle_tool_highlight();
|
||||
task
|
||||
}
|
||||
|
||||
fn dispatch_command_inner(&mut self, cmd: &str, allow_suggest: bool) -> Task<Message> {
|
||||
@@ -206,6 +227,13 @@ impl OpenCADStudio {
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
// Light the ribbon button of the command now starting, however it was
|
||||
// started (typed, alias, shortcut, Repeat), so the user sees where it
|
||||
// lives. A transparent command returned above and leaves the running
|
||||
// command's highlight alone.
|
||||
self.ribbon
|
||||
.show_command(strip_command_prefixes(cmd).unwrap_or(cmd));
|
||||
|
||||
if !self.suppress_plugin_dispatch && crate::plugin::try_dispatch(self, i, cmd) {
|
||||
// try_dispatch returns true for both finished commands and interactive
|
||||
// commands that it just installed. If no command is now active, the
|
||||
@@ -1084,3 +1112,44 @@ fn strip_command_prefixes(cmd: &str) -> Option<&str> {
|
||||
let bare = cmd.trim_start_matches(['_', '.']);
|
||||
(bare.len() != cmd.len() && !bare.is_empty()).then_some(bare)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ribbon_highlight_tests {
|
||||
use crate::app::OpenCADStudio;
|
||||
|
||||
fn fresh() -> OpenCADStudio {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.automation_op(r#"{"op":"new"}"#);
|
||||
app
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_typed_alias_lights_the_button_of_the_command_it_starts() {
|
||||
let mut app = fresh();
|
||||
let _ = app.dispatch_command_or_suggest("O");
|
||||
assert!(app.tabs[app.active_tab].active_cmd.is_some());
|
||||
assert_eq!(app.ribbon.active_tool(), Some("OFFSET"));
|
||||
let _ = app.dispatch_command_or_suggest("L");
|
||||
assert_eq!(app.ribbon.active_tool(), Some("LINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transparent_command_keeps_the_running_command_lit() {
|
||||
let mut app = fresh();
|
||||
let _ = app.dispatch_command_or_suggest("LINE");
|
||||
let _ = app.dispatch_command_or_suggest("'ZOOM E");
|
||||
assert!(app.tabs[app.active_tab].active_cmd.is_some());
|
||||
assert_eq!(app.ribbon.active_tool(), Some("LINE"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_one_shot_command_leaves_nothing_lit() {
|
||||
// REGEN cancels the running LINE and finishes at once, so neither
|
||||
// its own button nor LINE's may stay lit.
|
||||
let mut app = fresh();
|
||||
let _ = app.dispatch_command_or_suggest("LINE");
|
||||
let _ = app.dispatch_command_or_suggest("REGEN");
|
||||
assert!(app.tabs[app.active_tab].active_cmd.is_none());
|
||||
assert_eq!(app.ribbon.active_tool(), None);
|
||||
}
|
||||
}
|
||||
@@ -1086,6 +1086,7 @@ impl OpenCADStudio {
|
||||
| "CONSTRAINTNAMEFORMAT"
|
||||
| "DYNCONSTRAINTDISPLAY"
|
||||
| "CCONSTRAINTFORM"
|
||||
| "DYNMODE"
|
||||
) =>
|
||||
{
|
||||
return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i);
|
||||
@@ -1110,7 +1111,7 @@ impl OpenCADStudio {
|
||||
self.command_line.push_info(&super::plotvars::setvar_listing());
|
||||
self.command_line.push_info(&super::blockvars::setvar_listing());
|
||||
self.command_line.push_info(
|
||||
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
|
||||
crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR SHORTCUTMENU SHORTCUTMENUDURATION CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY CONSTRAINTBARMODE CONSTRAINTNAMEFORMAT DYNCONSTRAINTDISPLAY DYNMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(),
|
||||
);
|
||||
} else {
|
||||
// Point cloud settings, kept with the drawing.
|
||||
@@ -1703,6 +1704,17 @@ impl OpenCADStudio {
|
||||
.ok_or_else(|| "SETVAR: 0 or 1 required.".into()),
|
||||
None => Ok((format!("MIRRTEXT = {}", h.mirror_text as i32), false)),
|
||||
},
|
||||
"DYNMODE" => match &value {
|
||||
Some(v) => match v.parse::<i16>() {
|
||||
Ok(mode) if (-3..=3).contains(&mode) => {
|
||||
self.dyn_mode = mode;
|
||||
self.dyn_input = mode > 0;
|
||||
Ok((format!("DYNMODE = {mode}"), true))
|
||||
}
|
||||
_ => Err("SETVAR: integer from -3 to 3 required.".into()),
|
||||
},
|
||||
None => Ok((format!("DYNMODE = {}", self.dyn_mode), false)),
|
||||
},
|
||||
"ZOOMWHEEL" => match &value {
|
||||
Some(v) => match parse_bool(v) {
|
||||
Some(reversed) => {
|
||||
@@ -2685,6 +2697,7 @@ impl OpenCADStudio {
|
||||
// Without this it marked the drawing
|
||||
// modified instead of persisting.
|
||||
| "TEXTFILL"
|
||||
| "DYNMODE"
|
||||
) {
|
||||
self.persist_settings_if_changed();
|
||||
} else {
|
||||
@@ -3782,6 +3795,51 @@ mod tests {
|
||||
let _ = app.run_command_line("SETVAR DELOBJ 4");
|
||||
assert_eq!(app.delete_objects, 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynmode_direct_and_setvar_toggles_dynamic_input() {
|
||||
let mut app = fresh_app();
|
||||
assert!(app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 3);
|
||||
|
||||
// Turn off via direct command: DYNMODE 0
|
||||
let _ = app.run_command_line("DYNMODE 0");
|
||||
assert!(!app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 0);
|
||||
|
||||
// Turn on via direct command: DYNMODE 3
|
||||
let _ = app.run_command_line("DYNMODE 3");
|
||||
assert!(app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 3);
|
||||
|
||||
// Turn off via SETVAR: SETVAR DYNMODE 0
|
||||
let _ = app.run_command_line("SETVAR DYNMODE 0");
|
||||
assert!(!app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 0);
|
||||
|
||||
// Turn on via SETVAR: SETVAR DYNMODE 1 (pointer input only)
|
||||
let _ = app.run_command_line("SETVAR DYNMODE 1");
|
||||
assert!(app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 1);
|
||||
|
||||
// Out of range rejected
|
||||
let _ = app.run_command_line("DYNMODE 5");
|
||||
assert_eq!(app.dyn_mode, 1);
|
||||
assert!(app.dyn_input);
|
||||
|
||||
let _ = app.run_command_line("DYNMODE -5");
|
||||
assert_eq!(app.dyn_mode, 1);
|
||||
assert!(app.dyn_input);
|
||||
|
||||
// F12 turns it off temporarily (negative) and restores the mode.
|
||||
let _ = app.update(crate::app::Message::ToggleDynInput);
|
||||
assert!(!app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, -1);
|
||||
|
||||
let _ = app.update(crate::app::Message::ToggleDynInput);
|
||||
assert!(app.dyn_input);
|
||||
assert_eq!(app.dyn_mode, 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+170
-49
@@ -135,63 +135,130 @@ fn parse_point_2d(val: &Value) -> Result<[f64; 2], Value> {
|
||||
Ok([x, y])
|
||||
}
|
||||
|
||||
fn parse_calibrate(
|
||||
cal: &Value,
|
||||
pixel_width: u32,
|
||||
pixel_height: u32,
|
||||
) -> Result<(codec::types::Vector3, f64, f64), Value> {
|
||||
let pt_a = cal
|
||||
.get("point_a")
|
||||
.or_else(|| cal.get("pixel_a"))
|
||||
.or_else(|| cal.get("p1"))
|
||||
.or_else(|| cal.get("source_a"));
|
||||
let pt_b = cal
|
||||
.get("point_b")
|
||||
.or_else(|| cal.get("pixel_b"))
|
||||
.or_else(|| cal.get("p2"))
|
||||
.or_else(|| cal.get("source_b"));
|
||||
let dist = cal
|
||||
.get("distance")
|
||||
.or_else(|| cal.get("real_distance"))
|
||||
.or_else(|| cal.get("length"))
|
||||
.and_then(Value::as_f64);
|
||||
|
||||
if let (Some(pa), Some(pb), Some(d_real)) = (pt_a, pt_b, dist) {
|
||||
if !d_real.is_finite() || d_real <= 0.0 {
|
||||
return Err(failure(
|
||||
"invalid_distance",
|
||||
"Calibration distance must be positive",
|
||||
));
|
||||
}
|
||||
let p1 = parse_point_2d(pa)?;
|
||||
let p2 = parse_point_2d(pb)?;
|
||||
let d_px = ((p2[0] - p1[0]).powi(2) + (p2[1] - p1[1]).powi(2)).sqrt();
|
||||
if d_px <= 1e-6 {
|
||||
return Err(failure(
|
||||
"invalid_points",
|
||||
"Calibration reference points must be distinct",
|
||||
));
|
||||
}
|
||||
let scale = d_real / d_px;
|
||||
let width = pixel_width as f64 * scale;
|
||||
|
||||
let align_to = cal
|
||||
.get("align_to")
|
||||
.or_else(|| cal.get("origin"))
|
||||
.or_else(|| cal.get("target_a"))
|
||||
.map(parse_point_2d)
|
||||
.transpose()?
|
||||
.unwrap_or([0.0, 0.0]);
|
||||
|
||||
let x_ll = align_to[0] - p1[0] * scale;
|
||||
let y_ll = align_to[1] - (pixel_height as f64 - p1[1]) * scale;
|
||||
return Ok((codec::types::Vector3::new(x_ll, y_ll, 0.0), width, scale));
|
||||
}
|
||||
|
||||
if let (Some(src_pts), Some(tgt_pts)) = (
|
||||
cal.get("source_points").and_then(Value::as_array),
|
||||
cal.get("target_points").and_then(Value::as_array),
|
||||
) {
|
||||
if src_pts.len() >= 2 && tgt_pts.len() >= 2 {
|
||||
let p_s1 = parse_point_2d(&src_pts[0])?;
|
||||
let p_s2 = parse_point_2d(&src_pts[1])?;
|
||||
let p_t1 = parse_point_2d(&tgt_pts[0])?;
|
||||
let p_t2 = parse_point_2d(&tgt_pts[1])?;
|
||||
let d_px = ((p_s2[0] - p_s1[0]).powi(2) + (p_s2[1] - p_s1[1]).powi(2)).sqrt();
|
||||
let d_cad = ((p_t2[0] - p_t1[0]).powi(2) + (p_t2[1] - p_t1[1]).powi(2)).sqrt();
|
||||
if d_px <= 1e-6 {
|
||||
return Err(failure(
|
||||
"invalid_points",
|
||||
"Source calibration points must be distinct",
|
||||
));
|
||||
}
|
||||
let scale = d_cad / d_px;
|
||||
let width = pixel_width as f64 * scale;
|
||||
let x_ll = p_t1[0] - p_s1[0] * scale;
|
||||
let y_ll = p_t1[1] - (pixel_height as f64 - p_s1[1]) * scale;
|
||||
return Ok((codec::types::Vector3::new(x_ll, y_ll, 0.0), width, scale));
|
||||
}
|
||||
}
|
||||
|
||||
Err(failure(
|
||||
"invalid_calibrate",
|
||||
"calibrate requires point_a:[x,y], point_b:[x,y], distance:number, and optional align_to:[x,y]",
|
||||
))
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// `embed_image` — pack the picture at `path` into an OLE2FRAME placed
|
||||
/// with its lower-left corner at `at` (default width ≈ pixel_width/100,
|
||||
/// like the interactive command's Enter answer) and commit it as one
|
||||
/// undo step. The drawing stays self-contained: no external file to lose.
|
||||
/// Supports automatic 2-point calibration via `source_points` and `target_points`.
|
||||
/// `embed_image` — pack the picture at `path` into an OLE2FRAME or linked RasterImage
|
||||
/// placed with its lower-left corner at `at` (default width ≈ pixel_width/100).
|
||||
/// Supports automatic reference scaling and calibration via `calibrate`:
|
||||
/// `{"point_a": [px1, py1], "point_b": [px2, py2], "distance": 6500.0, "align_to": [0, 0]}`.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) fn control_embed_image(&mut self, req: &Value) -> Result<Task<Message>, Value> {
|
||||
let i = self.active_tab;
|
||||
let path = string(req, "path")?;
|
||||
let image = crate::io::ole_embed::EmbeddedImage::from_file(std::path::Path::new(&path))
|
||||
.map_err(|e| failure("embed_failed", e))?;
|
||||
let (at, width) = if let (Some(src_pts), Some(tgt_pts)) = (
|
||||
|
||||
let (at, width, scale) = if let Some(cal) = req.get("calibrate") {
|
||||
parse_calibrate(cal, image.pixel_width, image.pixel_height)?
|
||||
} else if let (Some(src_pts), Some(tgt_pts)) = (
|
||||
req.get("source_points").and_then(Value::as_array),
|
||||
req.get("target_points").and_then(Value::as_array),
|
||||
) {
|
||||
if src_pts.len() >= 2 && tgt_pts.len() >= 2 {
|
||||
let p_s1 = parse_point_2d(&src_pts[0])?;
|
||||
let p_s2 = parse_point_2d(&src_pts[1])?;
|
||||
let p_t1 = parse_point_2d(&tgt_pts[0])?;
|
||||
let p_t2 = parse_point_2d(&tgt_pts[1])?;
|
||||
let d_px = ((p_s2[0] - p_s1[0]).powi(2) + (p_s2[1] - p_s1[1]).powi(2)).sqrt();
|
||||
let d_cad = ((p_t2[0] - p_t1[0]).powi(2) + (p_t2[1] - p_t1[1]).powi(2)).sqrt();
|
||||
if d_px <= 1e-6 {
|
||||
return Err(failure(
|
||||
"invalid_points",
|
||||
"Source calibration points must be distinct",
|
||||
));
|
||||
}
|
||||
let scale = d_cad / d_px;
|
||||
let w = image.pixel_width as f64 * scale;
|
||||
// In image pixel coords, y is 0 at top and pixel_height at bottom.
|
||||
// In CAD coordinates, lower-left is (x_ll, y_ll).
|
||||
// x_target = x_ll + px * scale => x_ll = x_target - px * scale
|
||||
// y_target = y_ll + (pixel_height - py) * scale => y_ll = y_target - (pixel_height - py) * scale
|
||||
let x_ll = p_t1[0] - p_s1[0] * scale;
|
||||
let y_ll = p_t1[1] - (image.pixel_height as f64 - p_s1[1]) * scale;
|
||||
let calc_at = codec::types::Vector3::new(x_ll, y_ll, 0.0);
|
||||
let final_at = embed_point(req).unwrap_or(calc_at);
|
||||
let final_w = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(w);
|
||||
(final_at, final_w)
|
||||
} else {
|
||||
let default_width = (image.pixel_width as f64 / 100.0).max(1.0);
|
||||
let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width);
|
||||
(embed_point(req)?, width)
|
||||
}
|
||||
let dummy = json!({"source_points": src_pts, "target_points": tgt_pts});
|
||||
parse_calibrate(&dummy, image.pixel_width, image.pixel_height)?
|
||||
} else {
|
||||
let default_width = (image.pixel_width as f64 / 100.0).max(1.0);
|
||||
let width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(default_width);
|
||||
(embed_point(req)?, width)
|
||||
let scale = width / image.pixel_width as f64;
|
||||
(embed_point(req)?, width, scale)
|
||||
};
|
||||
|
||||
let final_at = if req.get("calibrate").is_none() && req.get("source_points").is_none() {
|
||||
at
|
||||
} else {
|
||||
embed_point(req).unwrap_or(at)
|
||||
};
|
||||
let final_width = req["width"].as_f64().filter(|w| *w > 0.0).unwrap_or(width);
|
||||
let height = final_width * image.pixel_height as f64 / image.pixel_width as f64;
|
||||
|
||||
let layer_name = req["layer"].as_str().filter(|l| !l.is_empty());
|
||||
let lock_layer = req["lock_layer"].as_bool().unwrap_or(false);
|
||||
|
||||
if req["linked"].as_bool().unwrap_or(false) {
|
||||
// Path-linked RasterImage + ImageDefinition: the drawing stores
|
||||
// only the file path, so the picture file must travel with the
|
||||
// drawing (unlike the embedded OLE2FRAME default below).
|
||||
self.push_undo_snapshot(i, "IMAGEATTACH");
|
||||
let height = width * image.pixel_height as f64 / image.pixel_width as f64;
|
||||
let handle = {
|
||||
let document = &mut self.tabs[i].scene.document;
|
||||
let definition_handle = document.allocate_handle();
|
||||
@@ -207,45 +274,91 @@ impl OpenCADStudio {
|
||||
);
|
||||
let mut entity = codec::entities::RasterImage::with_size(
|
||||
path,
|
||||
at,
|
||||
final_at,
|
||||
image.pixel_width as f64,
|
||||
image.pixel_height as f64,
|
||||
width,
|
||||
final_width,
|
||||
height,
|
||||
);
|
||||
entity.definition_handle = Some(definition_handle);
|
||||
if let Some(l_name) = layer_name {
|
||||
entity.common.layer = l_name.to_string();
|
||||
if !document.layers.contains(l_name) {
|
||||
let mut layer_record = codec::tables::Layer::new(l_name.to_string());
|
||||
layer_record.handle = document.allocate_handle();
|
||||
if lock_layer {
|
||||
layer_record.flags.locked = true;
|
||||
}
|
||||
let _ = document.layers.add(layer_record);
|
||||
} else if lock_layer {
|
||||
if let Some(l) = document.layers.get_mut(l_name) {
|
||||
l.flags.locked = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
document
|
||||
.add_entity(codec::EntityType::RasterImage(entity))
|
||||
.map_err(|e| failure("embed_failed", e))?
|
||||
};
|
||||
self.tabs[i].scene.populate_images_from_document();
|
||||
if layer_name.is_some() {
|
||||
self.refresh_layer_panel();
|
||||
}
|
||||
self.post_ref_op(i);
|
||||
self.set_control_result(json!({
|
||||
"handle": format!("{:X}", handle.value()),
|
||||
"kind": "RasterImage",
|
||||
"path": path,
|
||||
"at": [at.x, at.y, at.z],
|
||||
"width": width,
|
||||
"at": [final_at.x, final_at.y, final_at.z],
|
||||
"width": final_width,
|
||||
"height": height,
|
||||
"scale": scale,
|
||||
"linked": true,
|
||||
"layer": layer_name.unwrap_or("0"),
|
||||
}));
|
||||
return Ok(Task::none());
|
||||
}
|
||||
|
||||
self.push_undo_snapshot(i, "IMAGEEMBED");
|
||||
let handle = crate::io::ole_embed::add_embedded_image(
|
||||
&mut self.tabs[i].scene.document,
|
||||
&image,
|
||||
at,
|
||||
width,
|
||||
final_at,
|
||||
final_width,
|
||||
)
|
||||
.map_err(|e| failure("embed_failed", e))?;
|
||||
|
||||
if let Some(l_name) = layer_name {
|
||||
let document = &mut self.tabs[i].scene.document;
|
||||
if !document.layers.contains(l_name) {
|
||||
let mut layer_record = codec::tables::Layer::new(l_name.to_string());
|
||||
layer_record.handle = document.allocate_handle();
|
||||
if lock_layer {
|
||||
layer_record.flags.locked = true;
|
||||
}
|
||||
let _ = document.layers.add(layer_record);
|
||||
} else if lock_layer {
|
||||
if let Some(l) = document.layers.get_mut(l_name) {
|
||||
l.flags.locked = true;
|
||||
}
|
||||
}
|
||||
if let Some(ent) = document.get_entity_mut(handle) {
|
||||
ent.common_mut().layer = l_name.to_string();
|
||||
}
|
||||
self.refresh_layer_panel();
|
||||
}
|
||||
|
||||
self.post_ref_op(i);
|
||||
self.set_control_result(json!({
|
||||
"handle": format!("{:X}", handle.value()),
|
||||
"kind": "Ole2Frame",
|
||||
"path": path,
|
||||
"at": [at.x, at.y, at.z],
|
||||
"width": width,
|
||||
"at": [final_at.x, final_at.y, final_at.z],
|
||||
"width": final_width,
|
||||
"height": height,
|
||||
"scale": scale,
|
||||
"linked": false,
|
||||
"layer": layer_name.unwrap_or("0"),
|
||||
}));
|
||||
Ok(Task::none())
|
||||
}
|
||||
@@ -363,6 +476,7 @@ impl OpenCADStudio {
|
||||
if !path.to_ascii_lowercase().ends_with(".pdf") {
|
||||
return Err(failure("invalid_path", "plot writes .pdf files"));
|
||||
}
|
||||
self.stamp_plot_fields();
|
||||
let i = self.active_tab;
|
||||
let names = self.tabs[i].scene.layout_names();
|
||||
let requested = req["layout"].as_str().unwrap_or("Model").to_owned();
|
||||
@@ -1023,6 +1137,13 @@ impl OpenCADStudio {
|
||||
"ss_custom" => F::SsCustom(v.into()),
|
||||
"ss_placeholder" => F::SsPlaceholder(index(fd::PLACEHOLDER_TYPES.len())?),
|
||||
"ss_scale" => F::SsScaleFormat(index(codec::fields::PLOT_SCALE_FORMATS.len())?),
|
||||
"placeholder" => F::PlaceholderProperty(index(codec::fields::BLOCK_PLACEHOLDER_PROPERTIES.len())?),
|
||||
"table_function" => F::TableFunction(
|
||||
["Average", "Sum", "Count", "Cell"]
|
||||
.into_iter()
|
||||
.find(|f| f.eq_ignore_ascii_case(v))
|
||||
.ok_or_else(|| failure("invalid_value", "Average|Sum|Count|Cell"))?,
|
||||
),
|
||||
_ => return Err(failure("invalid_value", "Unknown field dialog key")),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -528,6 +528,7 @@ impl OpenCADStudio {
|
||||
Task::none(),
|
||||
);
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if op == "tools" {
|
||||
return (
|
||||
json!({
|
||||
|
||||
@@ -44,6 +44,18 @@ impl DraftingSettingsState {
|
||||
}
|
||||
|
||||
impl OpenCADStudio {
|
||||
/// Turn Dynamic Input on or off with DYNMODE in step: off keeps the mode
|
||||
/// negative (temporarily off), on restores it, or 3 when there was none.
|
||||
pub(in crate::app) fn set_dyn_input(&mut self, on: bool) {
|
||||
self.dyn_input = on;
|
||||
let mode = self.dyn_mode.abs();
|
||||
self.dyn_mode = match (on, mode) {
|
||||
(true, 0) => 3,
|
||||
(true, mode) => mode,
|
||||
(false, mode) => -mode,
|
||||
};
|
||||
}
|
||||
|
||||
pub(super) fn drafting_settings_dirty(&self) -> bool {
|
||||
match (&self.drafting_settings_state, &self.drafting_settings_saved) {
|
||||
(Some(curr), Some(saved)) => curr.is_dirty(saved),
|
||||
@@ -100,7 +112,7 @@ impl OpenCADStudio {
|
||||
self.snapper.enabled = state.snap_modes.clone();
|
||||
self.snapper.snap3d_enabled = state.osnap3d_on;
|
||||
self.snapper.enabled3d = state.snap3d_modes.clone();
|
||||
self.dyn_input = state.dyn_input_on;
|
||||
self.set_dyn_input(state.dyn_input_on);
|
||||
self.quick_properties = state.quick_props_on;
|
||||
self.selection_cycling = state.selection_cycling_on;
|
||||
self.sync_vport_display(self.active_tab);
|
||||
|
||||
@@ -68,6 +68,15 @@ impl OpenCADStudio {
|
||||
self.sync_ribbon_layers();
|
||||
}
|
||||
|
||||
/// Pick up layers an entity edit registered on the fly (`ensure_layer`).
|
||||
pub(super) fn sync_registered_layers(&mut self, tab: usize) {
|
||||
if !std::mem::take(&mut self.tabs[tab].scene.layer_table_dirty) {
|
||||
return;
|
||||
}
|
||||
self.tabs[tab].dirty = true;
|
||||
self.refresh_layer_panel();
|
||||
}
|
||||
|
||||
pub(super) fn sync_ribbon_layers(&mut self) {
|
||||
let i = self.active_tab;
|
||||
// The Start (welcome) tab has no document — leave the layer and
|
||||
|
||||
@@ -615,6 +615,8 @@ pub(super) struct OpenCADStudio {
|
||||
pub grid_beyond_limits: bool,
|
||||
/// Dynamic input overlay (F12): show coordinate tooltip near cursor.
|
||||
dyn_input: bool,
|
||||
/// Dynamic input mode (DYNMODE): 0=off, 1=pointer, 2=dimensional, 3=both.
|
||||
dyn_mode: i16,
|
||||
/// Currently visible page in the application Options dialog.
|
||||
options_tab: crate::ui::window::options::OptionsTab,
|
||||
/// The Options window's commit point (see `options_session`).
|
||||
@@ -4146,6 +4148,7 @@ impl OpenCADStudio {
|
||||
grid_adaptive: true,
|
||||
grid_beyond_limits: true,
|
||||
dyn_input: true,
|
||||
dyn_mode: 3,
|
||||
options_tab: crate::ui::window::options::OptionsTab::General,
|
||||
options_saved: None,
|
||||
options_close_confirm: false,
|
||||
|
||||
@@ -350,6 +350,7 @@ impl<'a> HostSession<'a> {
|
||||
return handle;
|
||||
}
|
||||
let handle = self.app.tabs[self.tab].scene.add_entity(entity);
|
||||
self.app.sync_registered_layers(self.tab);
|
||||
self.publish_document_view();
|
||||
handle
|
||||
}
|
||||
@@ -362,6 +363,7 @@ impl<'a> HostSession<'a> {
|
||||
let _ = self.normalize_scripted_entity(None, entity);
|
||||
}
|
||||
let handles = self.app.tabs[self.tab].scene.add_entities(entities);
|
||||
self.app.sync_registered_layers(self.tab);
|
||||
self.publish_document_view();
|
||||
handles
|
||||
}
|
||||
@@ -381,6 +383,7 @@ impl<'a> HostSession<'a> {
|
||||
EntityType::AttributeEntity(attribute) => self.replace_nested_attribute(attribute),
|
||||
entity => self.app.tabs[self.tab].scene.update_entity(entity),
|
||||
};
|
||||
self.app.sync_registered_layers(self.tab);
|
||||
if ok {
|
||||
self.publish_document_view();
|
||||
}
|
||||
@@ -439,6 +442,7 @@ impl<'a> HostSession<'a> {
|
||||
};
|
||||
assert!(updated);
|
||||
}
|
||||
self.app.sync_registered_layers(self.tab);
|
||||
self.set_dirty();
|
||||
self.publish_document_view();
|
||||
Ok(())
|
||||
@@ -9697,6 +9701,44 @@ step('undo_move', lambda: M.move([u], (0, 0, 0), (7, 0, 0)))
|
||||
assert!(!host.modify_layer(non_existent));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_creation_with_novel_layer_refreshes_gui() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
app.tabs[0].is_start = false;
|
||||
|
||||
// Verify COGO_LINES does not exist initially
|
||||
assert!(!app.tabs[0].scene.document.layers.contains("COGO_LINES"));
|
||||
assert!(!app.ribbon.layer_names.contains(&"COGO_LINES".to_string()));
|
||||
|
||||
// 1. Add entity via HostSession (simulating plugin add_entity / add_line)
|
||||
{
|
||||
let mut host = HostSession::new(&mut app, 0);
|
||||
let mut line = codec::entities::Line::from_coords(0.0, 0.0, 0.0, 10.0, 10.0, 0.0);
|
||||
line.common.layer = "COGO_LINES".to_string();
|
||||
let handle = host.add_entity(codec::EntityType::Line(line));
|
||||
assert_ne!(handle, codec::Handle::NULL);
|
||||
}
|
||||
|
||||
// Verify layer is created in document AND refreshed in ribbon + layer panel
|
||||
assert!(app.tabs[0].scene.document.layers.contains("COGO_LINES"));
|
||||
assert!(app.ribbon.layer_names.contains(&"COGO_LINES".to_string()),
|
||||
"Ribbon layers should contain COGO_LINES after add_entity");
|
||||
assert!(app.tabs[0].layers.layers.iter().any(|l| l.name == "COGO_LINES"),
|
||||
"Layer panel should contain COGO_LINES after add_entity");
|
||||
|
||||
// 2. Commit entity via command driver (simulating interactive tool / palette commit)
|
||||
let mut line2 = codec::entities::Line::from_coords(10.0, 10.0, 0.0, 20.0, 20.0, 0.0);
|
||||
line2.common.layer = "SURVEY_TRAVERSE".to_string();
|
||||
let _ = app.commit_entity_handle_preserve_layer(codec::EntityType::Line(line2));
|
||||
|
||||
// Verify SURVEY_TRAVERSE layer is created and refreshed in ribbon + layer panel
|
||||
assert!(app.tabs[0].scene.document.layers.contains("SURVEY_TRAVERSE"));
|
||||
assert!(app.ribbon.layer_names.contains(&"SURVEY_TRAVERSE".to_string()),
|
||||
"Ribbon layers should contain SURVEY_TRAVERSE after commit_entity");
|
||||
assert!(app.tabs[0].layers.layers.iter().any(|l| l.name == "SURVEY_TRAVERSE"),
|
||||
"Layer panel should contain SURVEY_TRAVERSE after commit_entity");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_execute_command() {
|
||||
let mut app = OpenCADStudio::new_for_test();
|
||||
|
||||
@@ -3491,6 +3491,7 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={}
|
||||
if tracks_dimension_chain {
|
||||
self.tabs[i].scene.last_created_dimension = new_handle;
|
||||
}
|
||||
self.sync_registered_layers(i);
|
||||
new_handle
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ pub const MAX_SELECTED_GRIPS: usize = 4096;
|
||||
pub struct UserSettings {
|
||||
pub spacemouse: crate::input::spacemouse::Preferences,
|
||||
pub dyn_input: bool,
|
||||
pub dyn_mode: i16,
|
||||
pub polar: bool,
|
||||
pub polar_increment_deg: f32,
|
||||
pub zoom_wheel_reversed: bool,
|
||||
@@ -774,6 +775,7 @@ impl Default for UserSettings {
|
||||
Self {
|
||||
spacemouse: crate::input::spacemouse::Preferences::default(),
|
||||
dyn_input: true,
|
||||
dyn_mode: 3,
|
||||
polar: false,
|
||||
polar_increment_deg: 45.0,
|
||||
zoom_wheel_reversed: false,
|
||||
|
||||
@@ -77,6 +77,10 @@ impl OpenCADStudio {
|
||||
BlockPaletteMsg::ToggleOptions => {
|
||||
self.block_palette.options_collapsed ^= true;
|
||||
}
|
||||
// Browsers expose no folder picker.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
BlockPaletteMsg::LibraryBrowse => {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
BlockPaletteMsg::LibraryBrowse => {
|
||||
return Task::perform(
|
||||
async {
|
||||
|
||||
@@ -120,25 +120,9 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
self.ribbon.note_panel_tool(&tool_id);
|
||||
self.ribbon.activate_tool(&tool_id);
|
||||
match event {
|
||||
ModuleEvent::Command(cmd) => {
|
||||
let task = self.dispatch_command(&cmd);
|
||||
// One-shot tools (view changes, clipboard, toggles,
|
||||
// audits…) leave nothing running: no interactive
|
||||
// command and no dialog. Their highlight would stick
|
||||
// forever — turn it off now. Interactive commands and
|
||||
// dialog owners keep theirs; the command end / modal
|
||||
// close clears those. (#355)
|
||||
let i = self.active_tab;
|
||||
if self.tabs[i].active_cmd.is_none()
|
||||
&& self.active_modal.is_none()
|
||||
&& !self.tabs[i].pan_mode
|
||||
&& !self.tabs[i].orbit_mode
|
||||
&& !self.tabs[i].zoom_dynamic_mode
|
||||
{
|
||||
self.ribbon.deactivate_tool();
|
||||
}
|
||||
return task;
|
||||
}
|
||||
// `dispatch_command` turns a one-shot tool's highlight off
|
||||
// again once nothing is left running (#355).
|
||||
ModuleEvent::Command(cmd) => return self.dispatch_command(&cmd),
|
||||
ModuleEvent::OpenFileDialog => {
|
||||
self.command_line
|
||||
.push_info(crate::t!("Open DWG/DXF: not yet implemented.").as_ref());
|
||||
|
||||
+19
-1
@@ -659,6 +659,7 @@ impl OpenCADStudio {
|
||||
crate::app::settings::UserSettings {
|
||||
spacemouse: self.spacemouse_preferences,
|
||||
dyn_input: self.dyn_input,
|
||||
dyn_mode: self.dyn_mode,
|
||||
polar: self.polar_mode,
|
||||
polar_increment_deg: self.polar_increment_deg,
|
||||
zoom_wheel_reversed: self.zoom_wheel_reversed,
|
||||
@@ -750,7 +751,8 @@ impl OpenCADStudio {
|
||||
|
||||
/// Apply restored preferences to live state.
|
||||
pub(in crate::app) fn apply_settings(&mut self, s: &crate::app::settings::UserSettings) {
|
||||
self.dyn_input = s.dyn_input;
|
||||
self.dyn_mode = s.dyn_mode;
|
||||
self.set_dyn_input(s.dyn_input);
|
||||
self.polar_mode = s.polar;
|
||||
self.polar_increment_deg = s.polar_increment_deg;
|
||||
self.zoom_wheel_reversed = s.zoom_wheel_reversed;
|
||||
@@ -3768,7 +3770,20 @@ impl OpenCADStudio {
|
||||
task
|
||||
}
|
||||
|
||||
/// A plot gives its fields (PlotDate) the plot time, as the reference
|
||||
/// stores it; the hosts are redrawn before the plot reads the scene.
|
||||
pub(in crate::app) fn stamp_plot_fields(&mut self) {
|
||||
let i = self.active_tab;
|
||||
let hosts = crate::entities::field::stamp_plot_fields(&mut self.tabs[i].scene.document);
|
||||
if !hosts.is_empty() {
|
||||
let changes: Vec<_> = hosts.into_iter().map(|h| (h, crate::scene::ChangeKind::Modified)).collect();
|
||||
self.tabs[i].scene.bump_entities(&changes);
|
||||
self.tabs[i].dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn print_all_pages(&mut self) -> Result<Vec<crate::io::pdf_export::PdfPageInput>, String> {
|
||||
self.stamp_plot_fields();
|
||||
let available = self.tabs[self.active_tab].scene.layout_names();
|
||||
let selected: Vec<String> = self
|
||||
.print_all_layouts
|
||||
@@ -5341,6 +5356,9 @@ impl OpenCADStudio {
|
||||
}
|
||||
self.active_modal = None;
|
||||
self.reset_modal_geometry();
|
||||
if !preview {
|
||||
self.stamp_plot_fields();
|
||||
}
|
||||
|
||||
// Extents, Window and Display use one plot path in both spaces. Only
|
||||
// Paper-space Layout is special: it uses the physical sheet bounds.
|
||||
|
||||
@@ -4197,7 +4197,7 @@ impl OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleDynInput => {
|
||||
self.dyn_input ^= true;
|
||||
self.set_dyn_input(!self.dyn_input);
|
||||
Task::none()
|
||||
}
|
||||
Message::ToggleViewCube => {
|
||||
|
||||
Reference in new issue
Block a user