Merge branch 'main' into feature/parametric-constraint-system

# Conflicts:
#	src/app/automation.rs
#	src/app/mod.rs
#	src/ui/ribbon/widgets.rs
This commit is contained in:
Felix Riestra committed 2026-09-09 13:35:07 +02:00
commit e2d39b049d
114 files changed
+13641 -1685

No files matched your search

+272 -78
View File
@@ -18,7 +18,7 @@
use std::io::{BufRead, Write};
use std::path::PathBuf;
use serde_json::{json, Value};
use serde_json::{Value, json};
use super::OpenCADStudio;
@@ -138,6 +138,14 @@ fn v3(v: acadrust::types::Vector3) -> Value {
json!([v.x, v.y, v.z])
}
fn entity_type_matches(entity: &acadrust::EntityType, requested: &str) -> bool {
if crate::entities::names::ui_name(entity).eq_ignore_ascii_case(requested) {
return true;
}
let record_name = crate::entities::names::dxf_name(entity);
record_name != "ENTITY" && record_name.eq_ignore_ascii_case(requested)
}
/// One entity as JSON. Summary mode carries identity only, geometry adds the
/// entity's defining values, and full also includes its world bounds.
fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value {
@@ -194,15 +202,30 @@ fn entity_json(e: &acadrust::EntityType, detail: &str) -> Value {
}
E::Insert(ins) => {
map.insert("block".into(), json!(ins.block_name));
map.insert("position".into(), v3(ins.insert_point));
let attributes: serde_json::Map<String, Value> = ins
.attributes
.iter()
.map(|attribute| (attribute.tag.clone(), json!(attribute.value)))
.collect();
map.insert("attributes".into(), Value::Object(attributes));
}
_ => {}
}
if detail == "full" {
let bounds = e.as_entity().bounding_box();
map.insert("bounds".into(), json!({
"min":[bounds.min.x,bounds.min.y,bounds.min.z],
"max":[bounds.max.x,bounds.max.y,bounds.max.z]
}));
map.insert(
"bounds".into(),
json!({
"min":[bounds.min.x,bounds.min.y,bounds.min.z],
"max":[bounds.max.x,bounds.max.y,bounds.max.z]
}),
);
if let Ok(Value::Object(wrapper)) = serde_json::to_value(e) {
if let Some((_, properties)) = wrapper.into_iter().next() {
map.insert("properties".into(), properties);
}
}
}
obj
}
@@ -218,16 +241,24 @@ fn request_point(req: &Value, key: &str) -> Option<[f64; 2]> {
}
fn request_handle(value: &Value) -> Option<acadrust::Handle> {
value.as_str()
.and_then(|value| u64::from_str_radix(value.trim_start_matches("0x"), 16).ok())
value
.as_str()
.and_then(|value| {
let value = value
.strip_prefix("0x")
.or_else(|| value.strip_prefix("0X"))
.unwrap_or(value);
u64::from_str_radix(value, 16).ok()
})
.map(acadrust::Handle::new)
}
fn projected_fields(mut entity: Value, fields: Option<&Vec<Value>>) -> Value {
let Some(fields) = fields else { return entity };
let Some(source) = entity.as_object_mut() else { return entity };
let keep: std::collections::HashSet<&str> =
fields.iter().filter_map(Value::as_str).collect();
let Some(source) = entity.as_object_mut() else {
return entity;
};
let keep: std::collections::HashSet<&str> = fields.iter().filter_map(Value::as_str).collect();
source.retain(|key, _| key == "handle" || keep.contains(key.as_str()));
entity
}
@@ -244,7 +275,9 @@ impl OpenCADStudio {
return err(error);
}
if matches!(response["status"].as_str(), Some("accepted" | "running")) {
return self.control_request(json!({"op":"operation","request_id":id})).0;
return self
.control_request(json!({"op":"operation","request_id":id}))
.0;
}
return response;
}
@@ -327,7 +360,9 @@ impl OpenCADStudio {
let i = self.active_tab;
let before = self.tabs[i].scene.document.entities().count();
let error_revision = self.command_line.error_revision;
if let Err(error) = self.run_headless(&cmd) { return err(error); }
if let Err(error) = self.run_headless(&cmd) {
return err(error);
}
if self.command_line.error_revision != error_revision {
return err(self.command_line.last_error.clone().unwrap_or_default());
}
@@ -342,13 +377,15 @@ impl OpenCADStudio {
}
"entities" => self.entity_summary(),
"query" => self.entity_query(&req),
"records" => self.record_query(&req),
"record_schema" => self.record_schema(&req),
"capabilities" => self.record_capabilities(),
"layers" => {
let i = self.active_tab;
let offset = req["offset"].as_u64().unwrap_or(0) as usize;
let limit = req["limit"].as_u64().unwrap_or(1000).min(10_000) as usize;
let count = self.tabs[i].scene.document.layers.iter().count();
let layers: Vec<Value> = self
.tabs[i]
let layers: Vec<Value> = self.tabs[i]
.scene
.document
.layers
@@ -408,8 +445,14 @@ impl OpenCADStudio {
// By explicit handles (hex, as returned by `query`).
if let Some(arr) = req["handles"].as_array() {
for h in arr.iter().filter_map(|h| h.as_str()) {
if let Ok(v) = u64::from_str_radix(h.trim_start_matches("0x"), 16) {
self.tabs[i].scene.select_entity(acadrust::Handle::new(v), false);
let h = h
.strip_prefix("0x")
.or_else(|| h.strip_prefix("0X"))
.unwrap_or(h);
if let Ok(v) = u64::from_str_radix(h, 16) {
self.tabs[i]
.scene
.select_entity(acadrust::Handle::new(v), false);
}
}
}
@@ -421,11 +464,7 @@ impl OpenCADStudio {
.scene
.document
.entities()
.filter(|e| {
type_filter.is_none_or(|t| {
crate::entities::names::ui_name(e).eq_ignore_ascii_case(t)
})
})
.filter(|e| type_filter.is_none_or(|t| entity_type_matches(e, t)))
.filter(|e| layer_filter.is_none_or(|l| e.common().layer == l))
.map(|e| e.common().handle)
.collect();
@@ -446,8 +485,7 @@ impl OpenCADStudio {
return err("save: no \"path\" and the document has none");
};
#[cfg(not(target_arch = "wasm32"))]
let result =
self.save_tab_synchronously_protected(i, path.clone(), true);
let result = self.save_tab_synchronously_protected(i, path.clone(), true);
#[cfg(target_arch = "wasm32")]
let result = crate::io::save(&self.tabs[i].scene.document, &path)
.map_err(crate::io::SaveFailure::other);
@@ -472,19 +510,30 @@ impl OpenCADStudio {
self.drive_headless_task(task)
}
pub(super) fn drive_headless_task(&mut self, task: iced::Task<super::Message>) -> Result<(), String> {
pub(super) fn drive_headless_task(
&mut self,
task: iced::Task<super::Message>,
) -> Result<(), String> {
use iced::futures::StreamExt;
let mut streams = Vec::new();
if let Some(stream) = iced_runtime::task::into_stream(task) { streams.push(stream); }
if let Some(stream) = iced_runtime::task::into_stream(task) {
streams.push(stream);
}
while let Some(stream) = streams.last_mut() {
match iced::futures::executor::block_on(stream.next()) {
Some(iced_runtime::Action::Output(message)) => {
let next = self.update(message);
if let Some(stream) = iced_runtime::task::into_stream(next) { streams.push(stream); }
if let Some(stream) = iced_runtime::task::into_stream(next) {
streams.push(stream);
}
}
Some(iced_runtime::Action::Widget(_)) | Some(iced_runtime::Action::Tick) | Some(iced_runtime::Action::Reload) => {},
Some(iced_runtime::Action::Widget(_))
| Some(iced_runtime::Action::Tick)
| Some(iced_runtime::Action::Reload) => {}
Some(action) => return Err(format!("GUI runtime required for {action:?}")),
None => { streams.pop(); }
None => {
streams.pop();
}
}
}
self.finish_all_pending_history();
@@ -540,20 +589,36 @@ impl OpenCADStudio {
let layer_filter = req["layer"].as_str();
let handles: Option<std::collections::HashSet<u64>> = req["handles"]
.as_array()
.map(|values| values.iter().filter_map(request_handle).map(|h| h.value()).collect())
.or_else(|| request_handle(&req["handle"])
.map(|handle| std::iter::once(handle.value()).collect()));
.map(|values| {
values
.iter()
.filter_map(request_handle)
.map(|h| h.value())
.collect()
})
.or_else(|| {
request_handle(&req["handle"])
.map(|handle| std::iter::once(handle.value()).collect())
});
let near = request_point(req, "near");
let contains = request_point(req, "contains_point");
let bounds = req["bounds"].as_array().and_then(|values| {
(values.len() == 4).then(|| Some([
values[0].as_f64()?, values[1].as_f64()?,
values[2].as_f64()?, values[3].as_f64()?,
])).flatten()
(values.len() == 4)
.then(|| {
Some([
values[0].as_f64()?,
values[1].as_f64()?,
values[2].as_f64()?,
values[3].as_f64()?,
])
})
.flatten()
});
if req.get("handles").is_some() && handles.as_ref().is_some_and(|parsed| {
parsed.len() != req["handles"].as_array().map_or(0, Vec::len)
}) {
if req.get("handles").is_some()
&& handles
.as_ref()
.is_some_and(|parsed| parsed.len() != req["handles"].as_array().map_or(0, Vec::len))
{
return err("query handles contains an invalid hexadecimal handle");
}
if req.get("handle").is_some() && request_handle(&req["handle"]).is_none() {
@@ -565,10 +630,13 @@ impl OpenCADStudio {
if req.get("contains_point").is_some() && contains.is_none() {
return err("query contains_point expects two or three finite coordinates");
}
if req.get("bounds").is_some() && bounds.is_none_or(|bounds| {
!bounds.iter().all(|value| value.is_finite())
|| bounds[0] > bounds[2] || bounds[1] > bounds[3]
}) {
if req.get("bounds").is_some()
&& bounds.is_none_or(|bounds| {
!bounds.iter().all(|value| value.is_finite())
|| bounds[0] > bounds[2]
|| bounds[1] > bounds[3]
})
{
return err("query bounds expects finite [min_x,min_y,max_x,max_y]");
}
let detail = req["detail"].as_str().unwrap_or("geometry");
@@ -580,39 +648,47 @@ impl OpenCADStudio {
let mut matched = Vec::new();
for e in tab.scene.document.entities() {
if handles.as_ref().is_some_and(|handles| {
!handles.contains(&e.common().handle.value())
}) {
if handles
.as_ref()
.is_some_and(|handles| !handles.contains(&e.common().handle.value()))
{
continue;
}
if type_filter.is_some_and(|value| {
!crate::entities::names::ui_name(e).eq_ignore_ascii_case(value)
}) || layer_filter.is_some_and(|value| e.common().layer != value) {
if type_filter.is_some_and(|value| !entity_type_matches(e, value))
|| layer_filter.is_some_and(|value| e.common().layer != value)
{
continue;
}
if let Some(bounds) = bounds {
let entity_bounds = e.as_entity().bounding_box();
if entity_bounds.max.x < bounds[0] || entity_bounds.max.y < bounds[1]
|| entity_bounds.min.x > bounds[2] || entity_bounds.min.y > bounds[3]
if entity_bounds.max.x < bounds[0]
|| entity_bounds.max.y < bounds[1]
|| entity_bounds.min.x > bounds[2]
|| entity_bounds.min.y > bounds[3]
{
continue;
}
}
let curve = (near.is_some() || contains.is_some())
.then(|| crate::entities::curve::entity_curve_xy(e)).flatten();
.then(|| crate::entities::curve::entity_curve_xy(e))
.flatten();
if let Some(point) = contains {
let Some(curve) = curve.as_ref().filter(|curve| curve.is_closed()) else {
continue;
};
if !cadkernel::geom2d::contains(
std::slice::from_ref(curve), point,
std::slice::from_ref(curve),
point,
cadkernel::geom2d::Tolerance::default(),
) {
continue;
}
}
let nearest = near.and_then(|point| curve.as_ref()
.map(|curve| cadkernel::geom2d::closest_point(curve, point)));
let nearest = near.and_then(|point| {
curve
.as_ref()
.map(|curve| cadkernel::geom2d::closest_point(curve, point))
});
if near.is_some() && nearest.is_none() {
continue;
}
@@ -620,20 +696,29 @@ impl OpenCADStudio {
if let Some(nearest) = nearest {
let object = entity.as_object_mut().expect("entity JSON object");
object.insert("distance".into(), json!(nearest.distance));
object.insert("closest_point".into(),
json!([nearest.point[0], nearest.point[1]]));
object.insert(
"closest_point".into(),
json!([nearest.point[0], nearest.point[1]]),
);
object.insert("parameter".into(), json!(nearest.t));
}
matched.push((nearest.map(|nearest| nearest.distance), entity));
}
if near.is_some() {
matched.sort_by(|left, right| left.0.partial_cmp(&right.0)
.unwrap_or(std::cmp::Ordering::Equal));
matched.sort_by(|left, right| {
left.0
.partial_cmp(&right.0)
.unwrap_or(std::cmp::Ordering::Equal)
});
}
let count = matched.len();
let fields = req["fields"].as_array();
let entities: Vec<Value> = matched.into_iter().skip(offset).take(limit)
.map(|(_, entity)| projected_fields(entity, fields)).collect();
let entities: Vec<Value> = matched
.into_iter()
.skip(offset)
.take(limit)
.map(|(_, entity)| projected_fields(entity, fields))
.collect();
json!({
"ok": true,
"document_id":tab.id,
@@ -668,7 +753,10 @@ mod tests {
fn layout_notice_skips_grid_camera_and_scene_builds() {
let mut app = OpenCADStudio::new_for_test();
assert_eq!(app.automation_op(r#"{"op":"new"}"#)["ok"], true);
assert_eq!(app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"], true);
assert_eq!(
app.automation_op(r#"{"op":"run","cmd":"LINE 0,0 10,10"}"#)["ok"],
true
);
let i = app.active_tab;
let scene = &mut app.tabs[i].scene;
scene.document.add_layout("Review").unwrap();
@@ -749,6 +837,44 @@ mod tests {
assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false);
}
#[test]
fn block_reference_query_exposes_instance_attributes() {
use acadrust::entities::{AttributeEntity, EntityType, Insert};
use acadrust::types::Vector3;
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
let i = app.active_tab;
let mut insert = Insert::new("A_CPT", Vector3::new(12.0, 34.0, 5.0));
insert.common.layer = "Equipment".to_string();
insert
.attributes
.push(AttributeEntity::simple("COMPANY", "BPH"));
insert
.attributes
.push(AttributeEntity::simple("STATUS", "ACTIVE"));
app.tabs[i].scene.add_entity(EntityType::Insert(insert));
let result = app
.automation_op(r#"{"op":"query","type":"Insert","layer":"Equipment","detail":"full"}"#);
assert_eq!(result["count"], 1);
assert_eq!(result["entities"][0]["type"], "Block Reference");
assert_eq!(result["entities"][0]["block"], "A_CPT");
assert_eq!(
result["entities"][0]["position"],
serde_json::json!([12.0, 34.0, 5.0])
);
assert_eq!(result["entities"][0]["attributes"]["COMPANY"], "BPH");
assert_eq!(result["entities"][0]["attributes"]["STATUS"], "ACTIVE");
assert_eq!(
result["entities"][0]["properties"]["attributes"][0]["value"],
"BPH"
);
let selected = app.automation_op(r#"{"op":"select","type":"Insert"}"#);
assert_eq!(selected["selected"], 1);
}
#[test]
fn ucs_interactive_inline_args() {
// `UCS Z 90` must drive the interactive UCS command step-by-step (option
@@ -800,9 +926,7 @@ mod tests {
fn tilted_ucs_places_planar_entities_with_the_plane_normal() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.automation_op(
r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#,
);
app.automation_op(r#"{"op":"run","cmd":"UCS 3POINT 0,0,0 1,0,0 0,0,1"}"#);
app.automation_op(r#"{"op":"run","cmd":"CIRCLE 2,3 1"}"#);
let circle = app.tabs[app.active_tab]
@@ -842,7 +966,10 @@ mod tests {
h.linetype_scale
);
// No command should be left dangling.
assert!(app.tabs[i].active_cmd.is_none(), "command must have finished");
assert!(
app.tabs[i].active_cmd.is_none(),
"command must have finished"
);
}
#[test]
@@ -1004,7 +1131,9 @@ mod tests {
(20.0, 300.0),
]
};
let _ = app.update(Message::ViewportMove(iced::Point::new(path[0].0, path[0].1)));
let _ = app.update(Message::ViewportMove(iced::Point::new(
path[0].0, path[0].1,
)));
let _ = app.update(Message::ViewportLeftPress);
std::thread::sleep(std::time::Duration::from_millis(180));
for &(x, y) in &path {
@@ -1047,7 +1176,10 @@ mod tests {
// Two-step: bare command then the value, like typing 1 + Enter
// (feed_active_cmd is the same path the GUI submit offers first).
let _ = app.run_command_line("PICKDRAG");
assert!(app.tabs[app.active_tab].active_cmd.is_some(), "prompt must open");
assert!(
app.tabs[app.active_tab].active_cmd.is_some(),
"prompt must open"
);
let _ = app.feed_active_cmd("1");
assert!(app.pick_drag_rect, "PICKDRAG 1 via the prompt must switch");
}
@@ -1085,10 +1217,7 @@ mod tests {
let _ = app.run_command_line("MATCHPROP");
assert!(app.tabs[i].active_cmd.is_some(), "MATCHPROP must start");
let _ = app.feed_command(StepInput::EntityPick(src_h, glam::DVec3::ZERO));
let _ = app.feed_command(StepInput::SelectionComplete(vec![
dst_text_h,
dst_mtext_h,
]));
let _ = app.feed_command(StepInput::SelectionComplete(vec![dst_text_h, dst_mtext_h]));
let doc = &app.tabs[i].scene.document;
match doc.get_entity(dst_text_h) {
@@ -1140,7 +1269,10 @@ mod tests {
app.opening.is_none(),
"an already-open drawing must not start a load"
);
assert!(app.pending_opens.is_empty(), "and must not queue one either");
assert!(
app.pending_opens.is_empty(),
"and must not queue one either"
);
// The same file spelled differently (a `..` hop) is still the same file.
let indirect = canon.parent().unwrap().join("..").join(
@@ -1234,8 +1366,7 @@ mod tests {
assert_eq!(saved["ok"], true, "{label}: {}", saved["error"]);
let saved_again = app.automation_op(r#"{"op":"save"}"#);
assert_eq!(
saved_again["ok"],
true,
saved_again["ok"], true,
"normal save: {}",
saved_again["error"]
);
@@ -1253,10 +1384,8 @@ mod tests {
#[test]
fn save_then_open_round_trips() {
let mut app = OpenCADStudio::new_for_test();
let path = std::env::temp_dir().join(format!(
"ocs_automation_test_{}.dxf",
std::process::id()
));
let path =
std::env::temp_dir().join(format!("ocs_automation_test_{}.dxf", std::process::id()));
let _ = std::fs::remove_file(&path);
let p = path.to_string_lossy().replace('\\', "\\\\");
app.automation_op(r#"{"op":"new"}"#);
@@ -1321,4 +1450,69 @@ mod tests {
drop(app);
let _ = std::fs::remove_file(&path);
}
#[test]
fn test_pline_line_then_arc() {
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 PLINE
let _ = app.update(Message::CommandInput("PLINE".to_string()));
let _ = app.update(Message::CommandSubmit);
// Click first point (100, 100)
let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 100.0)));
let _ = app.update(Message::ViewportLeftPress);
let _ = app.update(Message::ViewportLeftRelease);
// Move to (200, 100) and click second point (draw line)
let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0)));
let _ = app.update(Message::ViewportLeftPress);
let _ = app.update(Message::ViewportLeftRelease);
let wid = app.main_window.unwrap_or_else(iced::window::Id::unique);
// Switch to arc: Option 1 - CommandOptionPick("A")
let _ = app.update(Message::CommandOptionPick("A".to_string()));
let _ = app.view(wid);
println!(
"ENTITIES: {}",
app.tabs[0].scene.document.entities().count()
);
println!(
"CMD: {:?}",
app.tabs[0].active_cmd.as_ref().map(|c| c.name())
);
// Move mouse!
let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 100.0)));
let _ = app.view(wid);
let _ = app.update(Message::ViewportMove(iced::Point::new(201.0, 100.0)));
let _ = app.view(wid);
let _ = app.update(Message::ViewportMove(iced::Point::new(200.0, 150.0)));
let _ = app.view(wid);
let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 150.0)));
let _ = app.view(wid);
// Click arc point
let _ = app.update(Message::ViewportLeftPress);
let _ = app.update(Message::ViewportLeftRelease);
// Move mouse again
let _ = app.update(Message::ViewportMove(iced::Point::new(150.0, 160.0)));
// Switch to line
let _ = app.update(Message::CommandOptionPick("L".to_string()));
// Move mouse again
let _ = app.update(Message::ViewportMove(iced::Point::new(100.0, 150.0)));
// Finish
let _ = app.update(Message::CommandOptionPick(String::new()));
}
}
+67 -4
View File
@@ -733,7 +733,10 @@ impl OpenCADStudio {
&result,
CmdResult::Relaunch(..)
| CmdResult::Dispatch(..)
| CmdResult::SolidEdgeBlend { .. }
| CmdResult::SolidSubtract { .. }
| CmdResult::SliceEntities { .. }
| CmdResult::SliceSurfaceEntities { .. }
);
let task = self.apply_cmd_result_inner(result);
let i = self.active_tab;
@@ -3091,6 +3094,14 @@ impl OpenCADStudio {
self.command_line.push_info(&prompt);
}
}
CmdResult::ReportError(msg) => {
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.command_line.push_error(&msg);
if let Some(prompt) = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()) {
self.command_line.push_info(&prompt);
}
}
CmdResult::ReportMeasurementAndDeselect(msg) => {
self.tabs[i].snap_result = None;
self.tabs[i].scene.deselect_all();
@@ -4268,11 +4279,20 @@ impl OpenCADStudio {
CmdResult::SolidEdgeBlend {
handle,
pick,
edges,
base_face,
value,
other_value,
fillet,
} => {
let task = self.solid_edge_blend(handle, pick, value, fillet);
let task = self.solid_edge_blend(
handle,
&edges,
base_face,
value,
other_value,
fillet,
);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
@@ -4280,8 +4300,51 @@ impl OpenCADStudio {
return task;
}
CmdResult::SolidSubtract { bases, cutters } => {
let task = self.solid_subtract(&bases, &cutters);
CmdResult::SolidShell {
handle,
actions,
distance,
} => {
let task = self.solid_shell(handle, &actions, distance);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
return task;
}
CmdResult::SolidSubtract {
bases,
cutters,
convert_meshes,
} => {
let task = self.solid_subtract(&bases, &cutters, convert_meshes);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
return task;
}
CmdResult::SliceEntities {
targets,
plane,
keep_point,
} => {
let task = self.solid_slice(&targets, plane, keep_point);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
self.restore_pre_cmd_tangent();
return task;
}
CmdResult::SliceSurfaceEntities {
targets,
cutter,
keep_point,
} => {
let task = self.solid_slice_surface(&targets, *cutter, keep_point);
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.tabs[i].scene.clear_preview_wire();
+92 -12
View File
@@ -1,6 +1,6 @@
use super::*;
fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option<acadrust::Handle> {
fn selected_edge_body(app: &OpenCADStudio, tab: usize) -> Option<acadrust::Handle> {
let scene = &app.tabs.get(tab)?.scene;
let selected = scene.selected_handles_in_order();
let [handle] = selected.as_slice() else {
@@ -9,7 +9,7 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option<acadrust::Handle> {
if matches!(
scene.document.get_entity(*handle),
Some(acadrust::EntityType::Solid3D(_))
Some(acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_))
) {
Some(*handle)
} else {
@@ -17,6 +17,36 @@ fn selected_solid(app: &OpenCADStudio, tab: usize) -> Option<acadrust::Handle> {
}
}
fn solid_edge_sources(
app: &mut OpenCADStudio,
tab: usize,
) -> Vec<(acadrust::Handle, cadkernel::brep::Body)> {
let handles = app.tabs[tab]
.scene
.document
.entities()
.filter_map(|entity| {
matches!(
entity,
acadrust::EntityType::Solid3D(_) | acadrust::EntityType::Surface(_)
)
.then_some(entity.common().handle)
})
.collect::<Vec<_>>();
app.tabs[tab].scene.restore_solid_models(&handles);
handles
.into_iter()
.filter_map(|handle| {
app.tabs[tab]
.scene
.solid_models
.get(&handle)
.cloned()
.map(|body| (handle, body))
})
.collect()
}
impl OpenCADStudio {
pub(super) fn dispatch_dim(&mut self, cmd: &str, i: usize) -> Option<Task<Message>> {
match cmd {
@@ -960,16 +990,30 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"SOLIDFILLET" => {
"FILLETEDGE" | "SOLIDFILLET" => {
use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand};
let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i));
let target = selected_edge_body(self, i);
let bodies = solid_edge_sources(self, i);
let command = SolidEdgeCommand::new(
EdgeOperation::Fillet,
target,
bodies,
crate::scene::WireModel::SELECTED,
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"FILLET" if selected_solid(self, i).is_some() => {
"FILLET" if selected_edge_body(self, i).is_some() => {
use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand};
let command = SolidEdgeCommand::new(EdgeOperation::Fillet, selected_solid(self, i));
let target = selected_edge_body(self, i);
let bodies = solid_edge_sources(self, i);
let command = SolidEdgeCommand::new(
EdgeOperation::Fillet,
target,
bodies,
crate::scene::WireModel::SELECTED,
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
@@ -1107,16 +1151,52 @@ impl OpenCADStudio {
}
}
"SOLIDCHAMFER" => {
use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand};
let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i));
"CHAMFEREDGE" | "SOLIDCHAMFER" => {
use crate::modules::model::edge_cmd::SolidEdgeCommand;
let target = selected_edge_body(self, i);
let header = &self.tabs[i].scene.document.header;
let distances = (header.chamfer_distance_a, header.chamfer_distance_b);
let bodies = solid_edge_sources(self, i);
let command = SolidEdgeCommand::new_chamfer(
target,
bodies,
crate::scene::WireModel::SELECTED,
distances,
);
let distances = command.chamfer_distances();
self.command_line.push_output(
crate::tf!(
"Distance1 = {:.4}, Distance2 = {:.4}",
distances.0,
distances.1
)
.as_ref(),
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
"CHAMFER" if selected_solid(self, i).is_some() => {
use crate::modules::model::edge_cmd::{EdgeOperation, SolidEdgeCommand};
let command = SolidEdgeCommand::new(EdgeOperation::Chamfer, selected_solid(self, i));
"CHAMFER" if selected_edge_body(self, i).is_some() => {
use crate::modules::model::edge_cmd::SolidEdgeCommand;
let target = selected_edge_body(self, i);
let header = &self.tabs[i].scene.document.header;
let distances = (header.chamfer_distance_a, header.chamfer_distance_b);
let bodies = solid_edge_sources(self, i);
let command = SolidEdgeCommand::new_chamfer(
target,
bodies,
crate::scene::WireModel::SELECTED,
distances,
);
let distances = command.chamfer_distances();
self.command_line.push_output(
crate::tf!(
"Distance1 = {:.4}, Distance2 = {:.4}",
distances.0,
distances.1
)
.as_ref(),
);
self.command_line.push_info(&command.prompt());
self.tabs[i].active_cmd = Some(Box::new(command));
}
+153 -64
View File
@@ -1082,54 +1082,95 @@ impl OpenCADStudio {
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
"SHELL" | "SOLIDEDIT" => {
use crate::modules::model::shell_cmd::ShellCommand;
let selected = self.tabs[i]
.scene
.selected_handles_in_order()
.into_iter()
.filter(|handle| !self.tabs[i].scene.is_layer_locked(*handle))
.filter(|handle| {
matches!(
self.tabs[i].scene.document.get_entity(*handle),
Some(acadrust::EntityType::Solid3D(_))
)
})
.collect::<Vec<_>>();
let target = (selected.len() == 1).then_some(selected[0]);
let new_cmd = if cmd == "SHELL" {
ShellCommand::direct(target)
} else {
ShellCommand::solid_edit(target)
};
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
// ── Solid booleans ─────────────────────────────────────────────
"UNION" | "INTERSECT" => {
"UNION" => {
use crate::modules::model::boolean_cmd::BoolOp;
if let Some(op) = BoolOp::from_id(cmd) {
let solid_count = {
let scene = &self.tabs[i].scene;
scene
.selected_handles_in_order()
.into_iter()
.filter(|handle| !scene.is_layer_locked(*handle))
.filter(|handle| {
matches!(
scene.document.get_entity(*handle),
Some(acadrust::EntityType::Solid3D(_))
)
})
.take(2)
.count()
};
if solid_count < 2 {
use crate::modules::draw::select::SelectObjectsCommand;
let selection = SelectObjectsCommand::new(cmd);
self.command_line.push_info(&selection.prompt());
self.tabs[i].active_cmd = Some(Box::new(selection));
} else {
return Some(self.solid_boolean(op));
}
if self.union_ready() {
return Some(self.solid_boolean(BoolOp::Union));
}
use crate::modules::draw::select::SelectObjectsCommand;
let selection = SelectObjectsCommand::plain("UNION", "UNIONAPPLY");
self.command_line.push_info(&selection.prompt());
self.tabs[i].active_cmd = Some(Box::new(selection));
}
"UNIONAPPLY" => {
use crate::modules::model::boolean_cmd::BoolOp;
return Some(self.solid_boolean(BoolOp::Union));
}
"INTERSECT" => {
use crate::modules::model::boolean_cmd::BoolOp;
if !self.intersect_ready() {
use crate::modules::draw::select::SelectObjectsCommand;
let selection = SelectObjectsCommand::new(cmd);
self.command_line.push_info(&selection.prompt());
self.tabs[i].active_cmd = Some(Box::new(selection));
} else {
return Some(self.solid_boolean(BoolOp::Intersect));
}
}
"SUBTRACT" => {
use crate::modules::model::boolean_cmd::SubtractCommand;
let bases = {
let (bases, bases_have_mesh) = {
let scene = &self.tabs[i].scene;
scene
let bases = scene
.selected_handles_in_order()
.into_iter()
.filter(|handle| !scene.is_layer_locked(*handle))
.filter(|handle| {
matches!(
scene.document.get_entity(*handle),
Some(acadrust::EntityType::Solid3D(_))
Some(
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Region(_)
| acadrust::EntityType::Surface(_)
| acadrust::EntityType::Mesh(_)
| acadrust::EntityType::PolygonMesh(_)
| acadrust::EntityType::PolyfaceMesh(_)
)
)
})
.collect()
.collect::<Vec<_>>();
let bases_have_mesh = bases.iter().any(|handle| {
matches!(
scene.document.get_entity(*handle),
Some(
acadrust::EntityType::Mesh(_)
| acadrust::EntityType::PolygonMesh(_)
| acadrust::EntityType::PolyfaceMesh(_)
)
)
});
(bases, bases_have_mesh)
};
self.tabs[i].scene.deselect_all();
let subtract = SubtractCommand::new(bases);
let subtract = SubtractCommand::new(bases, bases_have_mesh);
self.command_line.push_info(&subtract.prompt());
self.tabs[i].active_cmd = Some(Box::new(subtract));
}
@@ -1180,9 +1221,9 @@ impl OpenCADStudio {
// REGION — convert selected closed boundaries (closed polylines /
// circles) into Region entities (one wire loop each).
"REGION" | "REG" => {
use acadrust::entities::{Region, Wire};
use acadrust::entities::Region;
use acadrust::types::Vector3;
let mut loops: Vec<Vec<Vector3>> = Vec::new();
let mut regions = Vec::new();
for (_, e) in self.tabs[i].scene.selected_entities().iter() {
let supported = matches!(
e,
@@ -1190,32 +1231,39 @@ impl OpenCADStudio {
if pl.is_closed && pl.vertices.len() >= 3
) || matches!(e, acadrust::EntityType::Circle(_));
if supported {
let Some(curve) = crate::entities::curve::entity_curve(e) else {
let Some((plane, loops, true)) =
crate::scene::model::presspull_model::profile_geometry(e)
else {
continue;
};
loops.push(
crate::entities::curve::curve_points(&curve)
.into_iter()
.map(|point| Vector3::new(point[0], point[1], point[2]))
.collect(),
let Some(body) = cadkernel::brep::planar_region(plane, &loops) else {
continue;
};
let mut region = Region::new();
region.point_of_reference = Vector3::new(
plane.origin[0],
plane.origin[1],
plane.origin[2],
);
region.common.layer = self.tabs[i].active_layer.clone();
regions.push((region, body));
}
}
if loops.is_empty() {
if regions.is_empty() {
self.command_line
.push_error(crate::t!("REGION: select closed polylines or circles.").as_ref());
} else {
self.push_undo_snapshot(i, "REGION");
let count = loops.len();
for pts in loops {
let mut w = Wire::new();
let first = pts.first().copied().unwrap_or(Vector3::new(0.0, 0.0, 0.0));
w.points = pts;
let mut r = Region::new();
r.point_of_reference = first;
r.wires = vec![w];
r.common.layer = self.tabs[i].active_layer.clone();
self.tabs[i].scene.add_entity(acadrust::EntityType::Region(r));
let count = regions.len();
let mut created = Vec::with_capacity(count);
for (region, body) in regions {
let handle = self.add_region_model(region, body);
if handle.is_null() {
self.tabs[i].scene.rollback_new_entities(&created);
self.discard_last_undo_entry(i);
return Some(iced::Task::none());
}
created.push(handle);
}
self.tabs[i].dirty = true;
self.command_line
@@ -1370,21 +1418,51 @@ impl OpenCADStudio {
}
}
// SLICE [X|Y|Z] <value> [TOP|BOTTOM] — cut the selected solid with an
// axis-aligned plane, keeping the lower half by default.
"SLICE" | "SL" => {
use crate::command::SelectThenKeywordCommand;
let has_sel = !self.tabs[i].scene.selected_entities().is_empty();
let c = SelectThenKeywordCommand::new(
"SLICE",
"SLICE cutting-plane axis [X / Y / Z] (add TOP/BOTTOM by typing):",
vec![
("X", "X", Some("SLICE offset along X:")),
("Y", "Y", Some("SLICE offset along Y:")),
("Z", "Z", Some("SLICE offset along Z:")),
],
has_sel,
);
use crate::modules::model::slice_cmd::SliceCommand;
let (targets, centre, radius, view_normal) = {
let scene = &mut self.tabs[i].scene;
let mut targets = scene
.selected_handles_in_order()
.into_iter()
.filter(|handle| !scene.is_layer_locked(*handle))
.filter(|handle| {
matches!(
scene.document.get_entity(*handle),
Some(
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Surface(_)
)
)
})
.collect::<Vec<_>>();
scene.restore_solid_models(&targets);
targets.retain(|handle| scene.solid_models.contains_key(handle));
let bounds = targets
.iter()
.filter_map(|handle| {
crate::scene::model::solid_model::extent(&scene.solid_models[handle])
})
.fold(None::<([f64; 3], [f64; 3])>, |bounds, (low, high)| {
Some(match bounds {
None => (low, high),
Some((mut min, mut max)) => {
for axis in 0..3 {
min[axis] = min[axis].min(low[axis]);
max[axis] = max[axis].max(high[axis]);
}
(min, max)
}
})
});
let (centre, radius) = bounds.map_or((glam::DVec3::ZERO, 10.0), |(min, max)| {
let min = glam::DVec3::from_array(min);
let max = glam::DVec3::from_array(max);
((min + max) * 0.5, (max - min).length().max(2.0) * 0.65)
});
(targets, centre, radius, scene.active_gaze_dir().as_dvec3())
};
let c = SliceCommand::new(targets, view_normal, centre, radius);
self.command_line.push_info(&c.prompt());
self.tabs[i].active_cmd = Some(Box::new(c));
}
@@ -1403,7 +1481,18 @@ impl OpenCADStudio {
let value: Option<f64> = parts.get(val_idx).and_then(|s| s.parse().ok());
let keep_low = !parts.iter().any(|s| s == "TOP");
match value {
Some(v) => return Some(self.solid_slice(axis, v, keep_low)),
Some(v) => {
let (origin, x, normal) = match axis {
0 => ([v, 0.0, 0.0], [0.0, 1.0, 0.0], [1.0, 0.0, 0.0]),
1 => ([0.0, v, 0.0], [0.0, 0.0, 1.0], [0.0, 1.0, 0.0]),
_ => ([0.0, 0.0, v], [1.0, 0.0, 0.0], [0.0, 0.0, 1.0]),
};
let plane = cadkernel::space::Plane::orthonormal(origin, x, normal)
.expect("fixed world axes define a plane");
let side = glam::DVec3::from_array(origin)
+ glam::DVec3::from_array(normal) * if keep_low { -1.0 } else { 1.0 };
return Some(self.slice_selected(plane, Some(side)));
}
None => self.command_line.push_info(
crate::t!("Usage: SLICE [X|Y|Z] <value> [TOP|BOTTOM] (cuts the selected solid)").as_ref(),
),
+4 -6
View File
@@ -335,9 +335,9 @@ impl OpenCADStudio {
"PERSP" => return Some(Task::done(Message::SetProjection(false))),
"LAYERS" => return Some(Task::done(Message::ToggleLayers)),
// SCRIPT <path> — run a command script: each non-blank, non-comment
// SCRIPT <path> — run a command script: each non-comment
// line is fed through the same command path the `--script` startup
// flag uses, so the behaviour matches headless automation exactly.
// flag uses. Blank rows submit Enter to the active command.
"SCRIPT" | "SCR" => {
use crate::command::ValuePromptCommand;
let c = ValuePromptCommand::new("SCRIPT", "SCRIPT path to the .scr file:");
@@ -352,10 +352,8 @@ impl OpenCADStudio {
let cmds: Vec<Task<Message>> = text
.lines()
.map(str::trim)
.filter(|l| {
!l.is_empty() && !l.starts_with('#') && !l.starts_with(';')
})
.map(|l| Task::done(Message::Command(l.to_string())))
.filter(|l| !l.starts_with('#') && !l.starts_with(';'))
.map(|l| Task::done(Message::ScriptLine(l.to_string())))
.collect();
self.command_line.push_output(crate::tf!(
"SCRIPT: running {} command(s) from {p}.",
+115 -13
View File
@@ -2,7 +2,7 @@
use super::{Message, OpenCADStudio};
use crate::command::{InputKind, StepInput};
use iced::Task;
use serde_json::{json, Value};
use serde_json::{Value, json};
use std::{collections::VecDeque, sync::OnceLock};
#[cfg(not(target_arch = "wasm32"))]
mod transport;
@@ -361,7 +361,7 @@ impl OpenCADStudio {
"mtext_editor":self.mtext_editor.as_ref().map(|e|json!({"text":e.content.text(),"height":e.height,"style":e.style})),
"text_editor":self.text_inline.is_some(),"event_cursor":self.control.serial,
"operation":self.control.pending.as_ref().map(|p| &p.id),
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
"capabilities":["commands","command_manifest","step_input","batch","compact_results","entity_pick","structure_pick","selection","properties","records","record_schemas","record_filters","atomic_record_updates","layers","history","documents","events","capture","viewport_capture","measure","spatial_query"]
})
}
@@ -377,6 +377,9 @@ impl OpenCADStudio {
| "properties"
| "measure"
| "query"
| "records"
| "record_schema"
| "capabilities"
| "entities"
| "layers"
| "header"
@@ -449,7 +452,9 @@ impl OpenCADStudio {
return (
failure(
"unknown_command",
format!("Unknown command {requested}; call commands without name to list commands"),
format!(
"Unknown command {requested}; call commands without name to list commands"
),
),
Task::none(),
);
@@ -511,7 +516,7 @@ impl OpenCADStudio {
"Supply a unique request_id (maximum 128 bytes)",
),
Task::none(),
)
);
}
};
if let Some((_, old, result)) = self.control.completed.iter().find(|(i, _, _)| i == &id)
@@ -773,6 +778,7 @@ impl OpenCADStudio {
Task::none()
}
"property" => self.control_set_property(req)?,
"set_properties" => self.control_set_record_properties(req)?,
"action" => self.control_ui_action(req)?,
#[cfg(not(target_arch = "wasm32"))]
"save" => {
@@ -814,6 +820,12 @@ impl OpenCADStudio {
})
}
pub(super) fn set_control_result(&mut self, value: Value) {
if let Some(operation) = self.control.pending.as_mut() {
operation.result = value;
}
}
pub(super) fn control_track(&mut self, task: Task<Message>) -> Task<Message> {
if task.units() == 0 {
return task;
@@ -982,7 +994,7 @@ impl OpenCADStudio {
"area":planar.curve.is_closed().then(|| planar.curve.enclosed_area().abs())
})
});
out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid}))}));
out.push(json!({"handle":format!("{:X}",handle.value()),"type":crate::entities::names::ui_name(entity),"bounds":{"min":[bb.min.x,bb.min.y,bb.min.z],"max":[bb.max.x,bb.max.y,bb.max.z]},"curve":curve,"mesh":metrics.map(|m|json!({"vertices":m.metrics.vertices,"triangles":m.metrics.triangles,"surface_area":m.metrics.surface_area,"volume":m.metrics.volume,"centroid":m.metrics.centroid,"moment_of_inertia":m.metrics.moment_of_inertia,"principal_directions":m.metrics.principal_directions,"principal_moments":m.metrics.principal_moments,"product_of_inertia":m.metrics.product_of_inertia,"radii_of_gyration":m.metrics.radii_of_gyration}))}));
}
json!({"ok":true,"document_id":tab.id,"geometry_revision":tab.scene.geometry_epoch,"measurements":out})
}
@@ -1101,10 +1113,12 @@ mod tests {
);
let started = request(&mut app, json!({"op":"start","cmd":"LINE"}));
assert_eq!(started["status"], "waiting_input");
assert!(started["state"]["command"]["accepts"]
.as_array()
.unwrap()
.contains(&json!("point")));
assert!(
started["state"]["command"]["accepts"]
.as_array()
.unwrap()
.contains(&json!("point"))
);
assert_eq!(
started["state"]["command"]["input_example"]["kind"],
"point"
@@ -1129,10 +1143,12 @@ mod tests {
);
assert_eq!(app.automation_op(r#"{"op":"entities"}"#)["total"], 1);
request(&mut app, json!({"op":"select","type":"LINE"}));
assert!(!app.control_properties()["sections"]
.as_array()
.unwrap()
.is_empty());
assert!(
!app.control_properties()["sections"]
.as_array()
.unwrap()
.is_empty()
);
let changed = request(&mut app, json!({"op":"property","field":"color","value":1}));
assert_eq!(changed["ok"], true, "{changed}");
assert_eq!(
@@ -1222,6 +1238,92 @@ mod tests {
assert!((curve["length"].as_f64().unwrap() - std::f64::consts::TAU * 2.0).abs() < 1e-9);
assert!((curve["area"].as_f64().unwrap() - std::f64::consts::PI * 4.0).abs() < 1e-9);
}
#[test]
fn control_shell_roundtrips_with_kernel_mass_properties() {
let mut app = OpenCADStudio::new_for_test();
request(&mut app, json!({"op":"new"}));
request(&mut app, json!({"op":"start","cmd":"BOX"}));
request(
&mut app,
json!({"op":"input","kind":"point","point":[0.0,0.0,0.0]}),
);
request(
&mut app,
json!({"op":"input","kind":"point","point":[6.0,5.0,0.0]}),
);
assert_eq!(
request(&mut app, json!({"op":"input","kind":"token","text":"4"}))["status"],
"completed"
);
let handle = app.tabs[app.active_tab]
.scene
.document
.entities()
.find_map(|entity| {
matches!(entity, acadrust::EntityType::Solid3D(_)).then(|| entity.common().handle)
})
.unwrap();
let handle_text = format!("{:X}", handle.value());
request(&mut app, json!({"op":"select","handles":[handle_text]}));
request(&mut app, json!({"op":"start","cmd":"SHELL"}));
request(
&mut app,
json!({"op":"input","kind":"entity","handle":handle_text,"point":[3.0,2.5,4.0]}),
);
request(&mut app, json!({"op":"input","kind":"enter"}));
assert_eq!(
request(&mut app, json!({"op":"input","kind":"token","text":"0.5"}))["status"],
"completed"
);
let measured = app.control_measure(&json!({"handles":[handle_text]}));
let mesh = &measured["measurements"][0]["mesh"];
assert!((mesh["volume"].as_f64().unwrap() - 50.0).abs() < 1e-9);
assert_eq!(mesh["principal_moments"].as_array().unwrap().len(), 3);
assert!(matches!(
app.tabs[app.active_tab]
.scene
.document
.solid_history_operation(handle),
Some(acadrust::objects::SolidHistoryOperation::Brep(_))
));
assert_eq!(request(&mut app, json!({"op":"undo"}))["ok"], true);
let restored = app.control_measure(&json!({"handles":[handle_text]}));
assert!(
(restored["measurements"][0]["mesh"]["volume"]
.as_f64()
.unwrap()
- 120.0)
.abs()
< 1e-9
);
assert_eq!(request(&mut app, json!({"op":"redo"}))["ok"], true);
let path = std::env::temp_dir().join(format!("ocs-shell-{}.dwg", session_id()));
assert_eq!(
request(&mut app, json!({"op":"save","path":path}))["ok"],
true
);
request(&mut app, json!({"op":"new"}));
assert_eq!(
request(&mut app, json!({"op":"open","path":path}))["status"],
"completed"
);
let reopened = app.control_measure(&json!({"handles":[handle_text]}));
assert!(
(reopened["measurements"][0]["mesh"]["volume"]
.as_f64()
.unwrap()
- 50.0)
.abs()
< 1e-9
);
let _ = std::fs::remove_file(path);
}
#[test]
fn control_retry_is_idempotent_and_stale_state_rejected() {
let mut app = OpenCADStudio::new_for_test();
+1 -9
View File
@@ -999,14 +999,6 @@ fn kernel_placement(p: &PlacementSpec) -> cadkernel::brep::Placement {
}
}
/// Signed volume and centroid of a closed triangle mesh via the divergence
/// theorem: V = Σ v0·(v1×v2)/6, C = Σ (v0+v1+v2)·tetra_vol / (4V). Single source
/// of truth for both (used by the cold-cache `volume`/`centroid` fallback).
fn mesh_volume_centroid(mesh: &cadkernel::brep::Mesh) -> (f64, [f64; 3]) {
// Delegate to the crate's single source of truth (divergence theorem).
ocs_doc_api::geom::mesh_volume_centroid(mesh)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -3235,7 +3227,7 @@ mod tests {
let body = crate::scene::convert::solid3d_tess::kernel_body(solid)
.expect("re-lift reloaded solid failed");
let mesh = cadkernel::brep::mesh_body(&body, 0.5, 1e-3);
let vol = mesh_volume_centroid(&mesh).0;
let vol = ocs_doc_api::geom::mesh_volume_centroid(&mesh).0;
assert!(
(vol - 125.0).abs() < 1.0,
"reloaded intersection volume {vol}"
+73 -60
View File
@@ -9,21 +9,22 @@ pub(crate) mod config;
pub use automation::{export_headless, serve};
mod command_driver;
pub(crate) mod commands;
#[cfg(not(target_arch = "wasm32"))]
mod doc_api;
mod document;
pub(crate) mod expr_eval;
mod find_replace;
pub(crate) mod helpers;
mod history;
#[cfg(not(target_arch = "wasm32"))]
mod doc_api;
mod layers;
mod model_ops;
mod mtext_editor;
#[cfg(not(target_arch = "wasm32"))]
pub mod plugin_host;
mod properties;
mod presspull_ops;
mod properties;
mod recent;
mod record_api;
pub(crate) mod settings;
mod shortcuts;
mod startup;
@@ -225,7 +226,10 @@ impl From<&QSelectState> for QSelectSettings {
Self {
scope: state.scope,
type_filter: state.type_filter.clone(),
property_field: state.property.as_ref().map(|property| property.field.clone()),
property_field: state
.property
.as_ref()
.map(|property| property.field.clone()),
operator: state.operator,
value: state.value.clone(),
mode: state.mode,
@@ -256,12 +260,12 @@ pub(crate) enum FindMatchKey {
}
use crate::snap::Snapper;
use crate::ui::{CommandLine, Ribbon, StatusBar};
use acadrust::types::{Color as AcadColor, LineWeight};
use acadrust::CadDocument;
use acadrust::types::{Color as AcadColor, LineWeight};
use iced::time::Instant;
use iced::window;
use iced::{mouse, Point, Task, Theme};
use iced::{Point, Task, Theme, mouse};
use std::sync::Arc;
pub(super) const POLY_START_DELAY_MS: u128 = 150;
@@ -318,9 +322,7 @@ struct AddSelectedRestore {
}
/// Which Start-page section a narrow (tabbed) Start page is showing.
#[derive(
Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize,
)]
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug, serde::Serialize, serde::Deserialize)]
pub enum StartSection {
Recent,
Videos,
@@ -780,15 +782,13 @@ pub(super) struct OpenCADStudio {
/// Live filter for installed and available plugin cards.
plugin_search_input: String,
/// Installable release tags fetched per linked repo (for the dropdown).
repo_release_tags:
rustc_hash::FxHashMap<String, Vec<crate::plugin::external::ReleaseInfo>>,
repo_release_tags: rustc_hash::FxHashMap<String, Vec<crate::plugin::external::ReleaseInfo>>,
/// The release tag currently selected per linked repo.
repo_selected_tag: rustc_hash::FxHashMap<String, String>,
/// Repository currently shown in the Plugin Manager detail pane.
selected_plugin_repo: Option<String>,
/// Parsed GitHub README content or the last fetch error, cached per repo.
plugin_readmes:
rustc_hash::FxHashMap<String, Result<iced::widget::markdown::Content, String>>,
plugin_readmes: rustc_hash::FxHashMap<String, Result<iced::widget::markdown::Content, String>>,
/// README requests in flight, used to render a deterministic loading state.
plugin_readme_loading: rustc_hash::FxHashSet<String>,
/// Last marketplace status / error line shown in the Plugin Manager.
@@ -1098,8 +1098,7 @@ pub(super) struct OpenCADStudio {
save_job_serial: u64,
/// Destination leases held while Save As workers are active.
#[cfg(not(target_arch = "wasm32"))]
pending_save_leases:
std::collections::HashMap<u64, crate::io::edit_lock::EditLease>,
pending_save_leases: std::collections::HashMap<u64, crate::io::edit_lock::EditLease>,
/// Locked-file failure currently shown in the recovery dialog.
#[cfg(not(target_arch = "wasm32"))]
pending_save_failure: Option<PendingSaveFailure>,
@@ -1448,7 +1447,8 @@ impl ClipboardDeps {
if !objects.is_empty() {
let mut annotation_scales = Vec::new();
for (_, object) in &objects {
let acadrust::objects::ObjectType::ObjectContextData(context) = object else {
let acadrust::objects::ObjectType::ObjectContextData(context) = object
else {
continue;
};
if annotation_scales
@@ -1618,9 +1618,7 @@ impl ClipboardDeps {
fn snapshot_block(doc: &acadrust::CadDocument, name: &str) -> Option<BlockDef> {
use acadrust::EntityType;
let br = doc.block_records.get(name)?;
if name.starts_with("*Model_Space")
|| name.starts_with("*Paper_Space")
|| br.flags.is_xref
if name.starts_with("*Model_Space") || name.starts_with("*Paper_Space") || br.flags.is_xref
{
return None;
}
@@ -1835,10 +1833,7 @@ pub enum Message {
/// Register a font already held by the shared web store with the UI renderer.
ApplyWebFont(crate::scene::text::web_font::Script),
/// Completion of the UI renderer's runtime font registration.
WebUiFontLoaded(
crate::scene::text::web_font::Script,
Result<(), String>,
),
WebUiFontLoaded(crate::scene::text::web_font::Script, Result<(), String>),
/// Ctrl+V. Routed by `update` into an open text editor, the drawing-object
/// clipboard, or the system text clipboard.
PasteShortcut,
@@ -1906,10 +1901,13 @@ pub enum Message {
WebFileOpened(u64, crate::io::WebOpenOutcome),
#[cfg(target_arch = "wasm32")]
WebFileCached(u64, crate::io::WebOpenOutcome, Result<(), String>),
FileOpened(u64, Result<
(String, PathBuf, CadDocument, crate::scene::DerivedCaches),
crate::io::OpenLoadError,
>),
FileOpened(
u64,
Result<
(String, PathBuf, CadDocument, crate::scene::DerivedCaches),
crate::io::OpenLoadError,
>,
),
RecoveryClose,
RecoveryAttempt,
RecoveryDecline,
@@ -2121,6 +2119,10 @@ pub enum Message {
CommandInput(String),
CommandSubmit,
Command(String),
/// Execute one complete line read from a command script. Unlike UI/ribbon
/// dispatch, this accepts an interactive verb and all of its arguments on
/// the same line (`BOX 0,0,0 10,10,0 10`).
ScriptLine(String),
/// Append one typed character to the command-line input from the
/// global key-press subscription. Used when the text-input widget
/// itself isn't focused (focus parked on viewport / button / etc.)
@@ -2150,9 +2152,16 @@ pub enum Message {
},
/// A widget captured Up/Down; resolve it only if the command input owns
/// keyboard focus.
CommandLineArrowProbe { direction: ArrowKey, extend_selection: bool },
CommandLineArrowProbe {
direction: ArrowKey,
extend_selection: bool,
},
/// Result of the command-input focus query for a captured Up/Down key.
CommandLineArrowResolved { direction: ArrowKey, focused: bool, extend_selection: bool },
CommandLineArrowResolved {
direction: ArrowKey,
focused: bool,
extend_selection: bool,
},
/// Toggle the dropdown listing the full command-line history.
CommandHistoryToggle,
/// Grab/move/release the expanded history panel's top resize edge.
@@ -2303,9 +2312,7 @@ pub enum Message {
epoch: u64,
source: usize,
wires: std::sync::Weak<Vec<crate::scene::WireModel>>,
index: std::sync::Arc<
crate::scene::pick::interaction_index::InteractionIndex,
>,
index: std::sync::Arc<crate::scene::pick::interaction_index::InteractionIndex>,
build_ms: f64,
},
WindowResized(f32, f32),
@@ -2567,7 +2574,10 @@ pub enum Message {
PropEditChoiceToggle,
/// User is typing in a block-attribute value field (live buffer update),
/// keyed by the attribute tag.
PropAttrInput { tag: String, value: String },
PropAttrInput {
tag: String,
value: String,
},
/// User committed a block-attribute value edit (Enter pressed).
PropAttrCommit(String),
/// Reports the currently keyboard-focused widget (if any) after a
@@ -2593,7 +2603,10 @@ pub enum Message {
PropColorFieldToggle(String),
/// User picked a colour for a generic per-field colour row (hatch gradient
/// `Color 1` / `Color 2`), routed by the field name.
PropColorFieldChanged { field: String, color: AcadColor },
PropColorFieldChanged {
field: String,
color: AcadColor,
},
/// Collapse the inline color picker dropdown. Fired when another
/// properties-panel dropdown (a combo_box) opens, so at most one panel
/// dropdown is open at a time and they can't overlap. (#235)
@@ -2705,7 +2718,10 @@ pub enum Message {
/// Select the row the Text Options / Properties tabs act on.
AttrEditorSelect(usize),
/// Live edit of the attribute value at row `idx` (Attribute tab).
AttrEditorInput { idx: usize, value: String },
AttrEditorInput {
idx: usize,
value: String,
},
// Text Options — all act on the selected row:
AttrEditorTextStyle(String),
AttrEditorJustify(String),
@@ -2760,9 +2776,7 @@ pub enum Message {
/// GitHub Discussions fetched at boot for the Start page.
DiscussionsFetched(Result<Vec<crate::discussions::DiscussionEntry>, String>),
/// Recent-file DWG preview thumbnails decoded on a background thread.
RecentThumbsLoaded(
Vec<(std::path::PathBuf, Option<iced::widget::image::Handle>)>,
),
RecentThumbsLoaded(Vec<(std::path::PathBuf, Option<iced::widget::image::Handle>)>),
/// Installable releases and manifest API versions fetched for `owner/repo`.
PluginReleasesFetched(
String,
@@ -2803,7 +2817,10 @@ pub enum Message {
InvertSelection,
/// Keyboard modifier state changed — tracks whether Shift is held so the
/// pick path can do subtractive (Shift+click) selection.
SetModifiers { shift: bool, ctrl: bool },
SetModifiers {
shift: bool,
ctrl: bool,
},
// ── In-place MText editor ───────────────────────────────────────────
/// Text-area edit action from the multi-line editor widget.
MTextEdit(iced::widget::text_editor::Action),
@@ -3273,10 +3290,7 @@ impl OpenCADStudio {
.iter()
.filter_map(|v| {
let bytes = v.thumb.clone()?;
Some((
v.id.clone(),
iced::widget::image::Handle::from_bytes(bytes),
))
Some((v.id.clone(), iced::widget::image::Handle::from_bytes(bytes)))
})
.collect();
self.videos = videos;
@@ -3790,8 +3804,7 @@ impl OpenCADStudio {
let state = Self::new();
let (id, open_task) = window::open(window::Settings {
maximized: true,
icon: build_window_icon()
.and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()),
icon: build_window_icon().and_then(|rgba| window::icon::from_rgba(rgba, 32, 32).ok()),
exit_on_close_request: false,
// A Wayland compositor has no StartupWMClass to go on: it resolves a
// window's dock icon by matching the window's app_id against the
@@ -3844,7 +3857,7 @@ impl OpenCADStudio {
Task::batch(
cfg.script_lines
.into_iter()
.map(|line| Task::done(Message::Command(line))),
.map(|line| Task::done(Message::ScriptLine(line))),
)
};
s.queue_startup_prompts();
@@ -3927,24 +3940,17 @@ impl OpenCADStudio {
let mut s = Self::new();
s.queue_startup_prompts();
let focus = s.focus_cmd_input();
let primary_font = crate::scene::text::web_font::preload_language(
&crate::i18n::active_language_tag(),
);
let primary_font =
crate::scene::text::web_font::preload_language(&crate::i18n::active_language_tag());
let fonts = Task::batch([
Task::done(Message::PollWebFonts),
Task::done(Message::ApplyWebFont(primary_font)),
]);
// Web can't reach the Patreon API directly (CORS); fetch the CI-built
// supporters.json served on the same origin instead.
let patrons = Task::perform(
crate::patreon::fetch_patrons_web(),
Message::PatronsFetched,
);
let patrons = Task::perform(crate::patreon::fetch_patrons_web(), Message::PatronsFetched);
s.videos_loading = true;
let videos = Task::perform(
crate::videos::fetch_playlist_web(),
Message::VideosFetched,
);
let videos = Task::perform(crate::videos::fetch_playlist_web(), Message::VideosFetched);
s.discussions_loading = true;
let discussions = Task::perform(
crate::discussions::fetch_discussions_web(),
@@ -3977,9 +3983,14 @@ pub fn run() -> iced::Result {
if let Some(tab) = state.tabs.get(state.active_tab) {
let dot = if tab.dirty { "● " } else { "" };
let name = tab.tab_display_name();
format!("{}OCS-{} - {}", dot, env!("OCS_BUILD_STAMP"), name)
format!(
"{}Open CAD Studio {} - {}",
dot,
env!("OCS_APP_VERSION"),
name
)
} else {
concat!("OCS-", env!("OCS_BUILD_STAMP")).to_string()
concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()
}
})
.theme(|state: &OpenCADStudio, _| state.active_theme.clone())
@@ -4011,7 +4022,9 @@ pub fn run_web() -> iced::Result {
OpenCADStudio::view_main,
)
.subscription(OpenCADStudio::subscription)
.title(|_state: &OpenCADStudio| concat!("OCS-", env!("OCS_BUILD_STAMP")).to_string())
.title(|_state: &OpenCADStudio| {
concat!("Open CAD Studio ", env!("OCS_APP_VERSION")).to_string()
})
.theme(|state: &OpenCADStudio| state.active_theme.clone())
.backend(iced::Backend::Hardware(iced::backend::Api::OpenGL))
.font(iced_aw::ICED_AW_FONT_BYTES)
+1641 -162
View File
File diff suppressed because it is too large. Load diff
+121 -62
View File
@@ -440,15 +440,15 @@ impl OpenCADStudio {
let display_entity = dispatch::entity_in_working_plane(contextual.as_ref(), plane);
let entity = &display_entity;
let group_names = self.tabs[i].scene.group_names_for_entity(handle);
let specialized_primitive =
crate::scene::model::solid_history::has_specialized_primitive_properties(
let compact_solid =
crate::scene::model::solid_history::has_compact_solid_properties(
&self.tabs[i].scene.document,
handle,
);
let mut sections =
dispatch::properties_sectioned(handle, entity, &text_style_names);
if specialized_primitive {
retain_specialized_primitive_sections(&mut sections);
if compact_solid {
retain_compact_solid_sections(&mut sections);
}
sections.extend(
crate::scene::model::solid_history::primitive_properties(
@@ -636,7 +636,7 @@ impl OpenCADStudio {
}
}
if !specialized_primitive && matches!(
if matches!(
entity,
acadrust::EntityType::Solid3D(_)
| acadrust::EntityType::Region(_)
@@ -654,38 +654,44 @@ impl OpenCADStudio {
{
use crate::entities::common::ro_prop;
let metrics = mesh.metrics;
let mut props = vec![
ro_prop(
t!("Vertices").as_ref(),
"mesh_vertices",
metrics.vertices.to_string(),
),
ro_prop(
t!("Triangles").as_ref(),
"mesh_triangles",
metrics.triangles.to_string(),
),
ro_prop(
t!("Surface Area").as_ref(),
"mesh_surface_area",
format!("{:.6}", metrics.surface_area),
),
ro_prop(
let triple = |values: [f64; 3]| {
format!("{:.6}, {:.6}, {:.6}", values[0], values[1], values[2])
};
let mut props = if compact_solid {
vec![ro_prop(
t!("Centroid").as_ref(),
"mesh_centroid",
format!(
"{:.6}, {:.6}, {:.6}",
metrics.centroid[0],
metrics.centroid[1],
metrics.centroid[2]
triple(metrics.centroid),
)]
} else {
vec![
ro_prop(
t!("Vertices").as_ref(),
"mesh_vertices",
metrics.vertices.to_string(),
),
),
ro_prop(
t!("Tessellation").as_ref(),
"mesh_complete",
if mesh.complete { "Complete" } else { "Partial" },
),
];
ro_prop(
t!("Triangles").as_ref(),
"mesh_triangles",
metrics.triangles.to_string(),
),
ro_prop(
t!("Surface Area").as_ref(),
"mesh_surface_area",
format!("{:.6}", metrics.surface_area),
),
ro_prop(
t!("Centroid").as_ref(),
"mesh_centroid",
triple(metrics.centroid),
),
ro_prop(
t!("Tessellation").as_ref(),
"mesh_complete",
if mesh.complete { "Complete" } else { "Partial" },
),
]
};
if mesh.complete
&& matches!(
entity,
@@ -694,14 +700,50 @@ impl OpenCADStudio {
| acadrust::EntityType::Body(_)
)
{
props.insert(
3,
let closed_props = vec![
ro_prop(
t!("Moment of inertia").as_ref(),
"mesh_moment_of_inertia",
triple(metrics.moment_of_inertia),
),
ro_prop(
t!("Principal directions").as_ref(),
"mesh_principal_directions",
metrics
.principal_directions
.chunks_exact(3)
.map(|axis| format!("({:.6}, {:.6}, {:.6})", axis[0], axis[1], axis[2]))
.collect::<Vec<_>>()
.join(", "),
),
ro_prop(
t!("Principal moments").as_ref(),
"mesh_principal_moments",
triple(metrics.principal_moments),
),
ro_prop(
t!("Product of inertia").as_ref(),
"mesh_product_of_inertia",
triple(metrics.product_of_inertia),
),
ro_prop(
t!("Radii of gyration").as_ref(),
"mesh_radii_of_gyration",
triple(metrics.radii_of_gyration),
),
ro_prop(
t!("Volume").as_ref(),
"mesh_volume",
format!("{:.6}", metrics.volume),
),
);
];
if compact_solid {
props.extend(closed_props);
} else {
let volume = closed_props.last().cloned().unwrap();
props.insert(3, volume);
props.extend(closed_props.into_iter().take(5));
}
}
sections.push(crate::scene::model::object::PropSection {
title: t!("Mass Properties").into_owned(),
@@ -710,7 +752,7 @@ impl OpenCADStudio {
}
}
if !specialized_primitive {
if !compact_solid {
sections.extend(crate::entities::object_data::sections(
&self.tabs[i].scene.document,
&self.tabs[i].scene.object_data_cache,
@@ -2068,8 +2110,8 @@ impl OpenCADStudio {
});
}
}
if specialized_primitive {
retain_specialized_primitive_sections(&mut sections);
if compact_solid {
retain_compact_solid_sections(&mut sections);
}
let title = match entity {
acadrust::EntityType::Insert(ins) => {
@@ -2166,7 +2208,7 @@ impl OpenCADStudio {
.collect();
let mut sections = aggregate_sections(&local_refs, &text_style_names);
if local_refs.iter().all(|(handle, _)| {
crate::scene::model::solid_history::has_specialized_primitive_properties(
crate::scene::model::solid_history::has_compact_solid_properties(
&self.tabs[i].scene.document,
*handle,
)
@@ -2591,7 +2633,7 @@ impl OpenCADStudio {
&mut self,
entity: acadrust::EntityType,
) -> Option<Handle> {
self.commit_entity_handle_with_policies(entity, false, false)
self.commit_entity_handle_with_policies(entity, false, false, false)
}
pub(super) fn commit_entity_handle_with_dimension_policy(
@@ -2603,6 +2645,7 @@ impl OpenCADStudio {
entity,
preserve_dimension_layer_and_style,
false,
false,
)
}
@@ -2610,7 +2653,14 @@ impl OpenCADStudio {
&mut self,
entity: acadrust::EntityType,
) -> Option<Handle> {
self.commit_entity_handle_with_policies(entity, false, true)
self.commit_entity_handle_with_policies(entity, false, true, false)
}
pub(super) fn commit_entity_handle_preserve_style(
&mut self,
entity: acadrust::EntityType,
) -> Option<Handle> {
self.commit_entity_handle_with_policies(entity, false, true, true)
}
fn commit_entity_handle_with_policies(
@@ -2618,6 +2668,7 @@ impl OpenCADStudio {
mut entity: acadrust::EntityType,
preserve_dimension_layer_and_style: bool,
preserve_entity_layer: bool,
preserve_entity_style: bool,
) -> Option<Handle> {
let i = self.active_tab;
let tracks_dimension_chain = matches!(
@@ -2735,25 +2786,32 @@ impl OpenCADStudio {
}
}
crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color);
crate::scene::view::dispatch::apply_common_prop(
&mut entity,
"linetype",
&self.ribbon.active_linetype.clone(),
);
crate::scene::view::dispatch::apply_line_weight(&mut entity, self.ribbon.active_lineweight);
// CELTSCALE (header.current_entity_linetype_scale): new entities
// pick up the document's saved per-entity linetype scale. The user
// can override per entity later via the properties panel.
let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale;
if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 {
entity.common_mut().linetype_scale = celtscale;
if !preserve_entity_style {
crate::scene::view::dispatch::apply_color(&mut entity, self.ribbon.active_color);
crate::scene::view::dispatch::apply_common_prop(
&mut entity,
"linetype",
&self.ribbon.active_linetype.clone(),
);
crate::scene::view::dispatch::apply_line_weight(
&mut entity,
self.ribbon.active_lineweight,
);
// CELTSCALE (header.current_entity_linetype_scale): new entities
// pick up the document's saved per-entity linetype scale. The user
// can override per entity later via the properties panel.
let celtscale = self.tabs[i].scene.document.header.current_entity_linetype_scale;
if (celtscale - 1.0).abs() > 1e-9 && celtscale.abs() > 1e-9 {
entity.common_mut().linetype_scale = celtscale;
}
}
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut entity,
);
if !preserve_entity_style {
crate::scene::creation_style::apply_current_creation_styles(
&self.tabs[i].scene.document,
&mut entity,
);
}
if let (Some((layer, style_name)), acadrust::EntityType::Dimension(dimension)) =
(inherited_dimension, &mut entity)
{
@@ -3061,7 +3119,7 @@ fn aggregate_solid_history_sections(
merged
}
fn retain_specialized_primitive_sections(
fn retain_compact_solid_sections(
sections: &mut Vec<crate::scene::model::object::PropSection>,
) {
sections.iter_mut().for_each(|section| {
@@ -3077,7 +3135,8 @@ fn retain_specialized_primitive_sections(
| "transparency"
| "hyperlink"
| "material"
) || crate::scene::model::solid_history::is_specialized_property(property.field)
) || property.field.starts_with("mesh_")
|| crate::scene::model::solid_history::is_specialized_property(property.field)
});
});
sections.retain(|section| !section.props.is_empty());
File diff suppressed because it is too large. Load diff
+8
View File
@@ -740,6 +740,14 @@ impl OpenCADStudio {
.as_mut()
.map(|c| c.on_preview_wires(cur))
.unwrap_or_default();
let preview_hidden = self.tabs[i]
.active_cmd
.as_ref()
.map(|command| command.preview_hidden_handles().to_vec())
.unwrap_or_default();
self.tabs[i]
.scene
.set_command_preview_hidden(&preview_hidden);
self.tabs[i].scene.set_preview_wires(previews);
}
}
+10 -1
View File
@@ -133,6 +133,9 @@ fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f
"A1" => Some(PaperSize::A1),
"A0" => Some(PaperSize::A0),
"A4" => Some(PaperSize::A4),
"Letter" => Some(PaperSize::Letter),
"Legal" => Some(PaperSize::Legal),
"Tabloid" => Some(PaperSize::Tabloid),
_ => None,
};
if let Some(paper) = standard {
@@ -4297,7 +4300,10 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
.as_deref()
.is_some_and(|name| name.eq_ignore_ascii_case(&style_name));
let (mut paper, orient) = paper_label_from_dims(ps.paper_width, ps.paper_height);
if !matches!(paper.as_str(), "A4" | "A3" | "A2" | "A1" | "A0")
if !matches!(
paper.as_str(),
"A4" | "A3" | "A2" | "A1" | "A0" | "Letter" | "Legal" | "Tabloid"
)
&& !ps.paper_size.is_empty()
{
paper = ps.paper_size.clone();
@@ -4401,6 +4407,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
"A2" => PaperSize::A2,
"A1" => PaperSize::A1,
"A0" => PaperSize::A0,
"Letter" => PaperSize::Letter,
"Legal" => PaperSize::Legal,
"Tabloid" => PaperSize::Tabloid,
_ => PaperSize::A4,
};
let orient = if d.orientation == "Portrait" {
+24
View File
@@ -265,6 +265,11 @@ impl OpenCADStudio {
self.control_observe_user_message(&msg);
let perf_started = crate::perf::enabled().then(Instant::now);
let perf_label = perf_message_label(&msg);
// Keep the stable tab id because dispatch may switch or close tabs.
let perf_edit_before = perf_started.map(|started| {
let tab = &self.tabs[self.active_tab];
(started, tab.id, tab.scene.geometry_epoch)
});
// A modal dialog must capture the keyboard the same way it already
// captures the mouse. Otherwise keystrokes from the global key
// subscription leak past the modal into the command line and fire as
@@ -324,6 +329,17 @@ impl OpenCADStudio {
self.otrack_active = None;
self.otrack_kind = None;
}
if let Some((started, tab_id, before)) = perf_edit_before {
if let Some(tab) = self.tabs.iter().find(|tab| tab.id == tab_id) {
if tab.scene.geometry_epoch != before {
tab.scene.record_nav_perf_caused(
crate::scene::NavPerfOp::Edit,
perf_label,
started,
);
}
}
}
if let Some(started) = perf_started {
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
if elapsed_ms >= 5.0 {
@@ -1953,6 +1969,14 @@ impl OpenCADStudio {
self.dispatch_command(&cmd)
}
Message::ScriptLine(line) => {
if line.trim().is_empty() {
self.feed_command(crate::command::StepInput::Enter)
} else {
self.run_command_line(&line)
}
}
Message::ToggleLayers => {
if self.active_modal == Some(super::ModalKind::Layers) {
self.ribbon.deactivate_tool_if("LAYERS");
+8
View File
@@ -2499,6 +2499,14 @@ impl OpenCADStudio {
previews.push(guide);
}
}
let preview_hidden = self.tabs[i]
.active_cmd
.as_ref()
.map(|command| command.preview_hidden_handles().to_vec())
.unwrap_or_default();
self.tabs[i]
.scene
.set_command_preview_hidden(&preview_hidden);
self.tabs[i].scene.set_preview_wires(previews);
} else {
self.tabs[i].snap_result = None;
-10
View File
@@ -2393,15 +2393,6 @@ impl OpenCADStudio {
let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal);
#[cfg(target_arch = "wasm32")]
let single_instance = Subscription::none();
// A second file opened via Finder while this process is already
// running (#1039) — delivered as a warm Apple Event straight to this
// process, bypassing `src/bin/ocs_launcher.rs` entirely (that only
// runs for the initial cold launch). See `io::macos_open_events`.
#[cfg(target_os = "macos")]
let macos_open_events =
crate::io::macos_open_events::subscribe().map(Message::OpenExternal);
#[cfg(not(target_os = "macos"))]
let macos_open_events = Subscription::none();
// Drain plugin-to-host requests on a timer when any plugin is loaded.
// This lets long-lived plugin sessions (e.g. the Python REPL) mutate the
// host document without requiring a user-generated message.
@@ -2577,7 +2568,6 @@ impl OpenCADStudio {
autosave,
plugin_drain,
single_instance,
macos_open_events,
hatch_pattern_keys,
keyboard_events,
])