forked from 1b-agent/opencadstudio-upstream
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:
commit
e2d39b049d
114 files changed
+13641
-1685
No files matched your search
+272
-78
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
+121
-62
@@ -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
@@ -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
@@ -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" {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
|
||||
Reference in new issue
Block a user