forked from 1b-agent/opencadstudio-upstream
Merge PR #1083: implement solid shell workflow
This commit is contained in:
commit
c5503e4758
19 files changed
+851
-115
No files matched your search
@@ -2929,6 +2929,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();
|
||||
@@ -4127,6 +4135,19 @@ impl OpenCADStudio {
|
||||
return task;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -971,6 +971,30 @@ 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" => {
|
||||
use crate::modules::model::boolean_cmd::BoolOp;
|
||||
|
||||
+87
-1
@@ -982,7 +982,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})
|
||||
}
|
||||
@@ -1222,6 +1222,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();
|
||||
|
||||
+138
-1
@@ -9,7 +9,7 @@ use acadrust::{
|
||||
};
|
||||
use cadkernel::brep::{Body, EdgeKey, FaceKey};
|
||||
use iced::Task;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use super::Message;
|
||||
use crate::modules::model::boolean_cmd::BoolOp;
|
||||
@@ -861,6 +861,143 @@ impl super::OpenCADStudio {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn solid_shell(
|
||||
&mut self,
|
||||
handle: Handle,
|
||||
actions: &[crate::command::ShellFaceAction],
|
||||
distance: f64,
|
||||
) -> Task<Message> {
|
||||
use crate::command::ShellFaceAction;
|
||||
|
||||
let i = self.active_tab;
|
||||
if self.reject_locked_edit(i, handle) {
|
||||
return Task::none();
|
||||
}
|
||||
if !matches!(
|
||||
self.tabs[i].scene.document.get_entity(handle),
|
||||
Some(EntityType::Solid3D(_))
|
||||
) {
|
||||
self.command_line
|
||||
.push_error(crate::t!("Select a 3D solid.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
if !distance.is_finite() || distance.abs() <= f64::EPSILON {
|
||||
self.command_line
|
||||
.push_error(crate::t!("The shell offset distance must be nonzero.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
self.tabs[i].scene.restore_solid_models(&[handle]);
|
||||
let Some(body) = self.tabs[i].scene.solid_models.get(&handle).cloned() else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("The solid geometry could not be restored.").as_ref());
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
let all_faces = body.face_keys().collect::<Vec<_>>();
|
||||
let mut removed = HashSet::new();
|
||||
for action in actions {
|
||||
match *action {
|
||||
ShellFaceAction::Remove(point) => {
|
||||
let Some(face) = cadkernel::brep::shell_face_at_point(&body, point.to_array())
|
||||
else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("A selected face could not be resolved.").as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
};
|
||||
removed.insert(face);
|
||||
}
|
||||
ShellFaceAction::Add(point) => {
|
||||
let Some(face) = cadkernel::brep::shell_face_at_point(&body, point.to_array())
|
||||
else {
|
||||
self.command_line.push_error(
|
||||
crate::t!("A selected face could not be resolved.").as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
};
|
||||
removed.remove(&face);
|
||||
}
|
||||
ShellFaceAction::RemoveAll => removed.extend(all_faces.iter().copied()),
|
||||
ShellFaceAction::AddAll => removed.clear(),
|
||||
}
|
||||
}
|
||||
let removed = removed.into_iter().collect::<Vec<_>>();
|
||||
|
||||
let result = match cadkernel::brep::shell(&body, &removed, distance) {
|
||||
Ok(result) => result,
|
||||
Err(cadkernel::brep::ShellError::InvalidDistance) => {
|
||||
self.command_line
|
||||
.push_error(crate::t!("The shell offset distance must be nonzero.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
Err(cadkernel::brep::ShellError::UnsupportedSolid) => {
|
||||
self.command_line.push_error(
|
||||
crate::t!("SHELL supports rectangular boxes, circular cylinders, and spheres.")
|
||||
.as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
Err(cadkernel::brep::ShellError::UnknownFace) => {
|
||||
self.command_line
|
||||
.push_error(crate::t!("A selected face does not belong to the solid.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
Err(cadkernel::brep::ShellError::NoMaterial) => {
|
||||
self.command_line.push_error(
|
||||
crate::t!("The offset removes all material. Use a smaller distance or add a face back.")
|
||||
.as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
Err(cadkernel::brep::ShellError::Kernel(_)) => {
|
||||
self.command_line.push_error(
|
||||
crate::t!("The shell could not be constructed from the selected solid and distance.")
|
||||
.as_ref(),
|
||||
);
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
|
||||
let Some(source) = self.tabs[i].scene.document.get_entity(handle).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let Some(entity) = entity_with_boolean_body(source, &result) else {
|
||||
self.command_line
|
||||
.push_error(crate::t!("The result could not be encoded as ACIS.").as_ref());
|
||||
return Task::none();
|
||||
};
|
||||
let Some(display) = self.tabs[i]
|
||||
.scene
|
||||
.prepare_solid_model_display(handle, &result)
|
||||
.filter(|display| display.0.complete)
|
||||
else {
|
||||
self.command_line.push_error(crate::t!("The result could not be displayed completely. The original solid was retained.").as_ref());
|
||||
return Task::none();
|
||||
};
|
||||
|
||||
self.push_undo_snapshot(i, "SHELL");
|
||||
self.tabs[i].scene.delete_solid_history(handle);
|
||||
if !self.tabs[i].scene.update_entity(entity) {
|
||||
self.command_line
|
||||
.push_error(crate::t!("The solid could not be updated.").as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.create_solid_history(handle, solid_history::brep_op(&result));
|
||||
self.tabs[i]
|
||||
.scene
|
||||
.register_prepared_solid_model(handle, result, display);
|
||||
self.tabs[i].scene.deselect_all();
|
||||
self.tabs[i].scene.select_entity(handle, false);
|
||||
self.tabs[i].dirty = true;
|
||||
self.refresh_properties();
|
||||
self.command_line
|
||||
.push_output(crate::t!("SHELL: solid updated.").as_ref());
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Intersect compatible selected solids, Regions, and coplanar Surfaces.
|
||||
pub(super) fn solid_intersect(&mut self) -> Task<Message> {
|
||||
let i = self.active_tab;
|
||||
|
||||
+77
-34
@@ -636,7 +636,7 @@ impl OpenCADStudio {
|
||||
}
|
||||
}
|
||||
|
||||
if !compact_solid && 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(),
|
||||
@@ -3093,7 +3135,8 @@ fn retain_compact_solid_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());
|
||||
|
||||
Reference in new issue
Block a user