feat: stabilize face attachments and closed spline workflows
This commit is contained in:
1 parent
c1d819f235
commit
a6bb8fc924
83 files changed
+6271
-385
No files matched your search
@@ -50,6 +50,7 @@ pub struct TreeState {
|
||||
pub occurrence_move: Option<OccMove>,
|
||||
/// The Redefine Sketch Plane panel (sketch id).
|
||||
pub redefine: Option<u64>,
|
||||
pub redefine_draft: Option<crate::sketch_attachment_ui::Draft>,
|
||||
/// Components picked in the tree (they have no viewport selection).
|
||||
pub picked_components: Vec<u64>,
|
||||
/// Canvases picked in the tree.
|
||||
@@ -112,6 +113,7 @@ pub fn set_sketch_visible(app: &mut SolveApp, id: u64, on: bool) {
|
||||
|
||||
pub fn redefine_sketch(app: &mut SolveApp, id: u64) {
|
||||
app.tree.redefine = Some(id);
|
||||
app.tree.redefine_draft = Some(crate::sketch_attachment_ui::Draft::new(app, id));
|
||||
}
|
||||
|
||||
fn find_group(app: &SolveApp, id: u64) -> Option<&solvecraft_engine::doc::BrowserGroup> {
|
||||
@@ -523,7 +525,8 @@ pub fn browser(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
occurrence_panel(app, ui);
|
||||
capture_prompt(app, ui.ctx());
|
||||
canvas_panel(app, ui.ctx());
|
||||
redefine_panel(app, ui.ctx());
|
||||
let repair_anchor = app.viewport.rect.map(|rect| rect.right_top() + vec2(-16.0, 16.0)).unwrap_or_else(|| ui.ctx().content_rect().right_top());
|
||||
crate::sketch_attachment_ui::panel(app, ui.ctx(), repair_anchor);
|
||||
}
|
||||
|
||||
fn component_name(app: &SolveApp, id: u64) -> String {
|
||||
@@ -1141,14 +1144,22 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
|
||||
.filter(|f| f.component == comp && matches!(f.kind, FeatureKind::Sketch { .. }))
|
||||
.map(|f| {
|
||||
let active = app.session.active_sketch == Some(f.id);
|
||||
let lost = app.session.model.result(f.id).is_some_and(|result| result.error.is_some())
|
||||
&& matches!(doc.sketch_face_attachment(f.id, &app.session.model), solvecraft_engine::doc::FaceAttachmentStatus::Lost { .. });
|
||||
Entry {
|
||||
key: f.id.to_string(),
|
||||
row_key: format!("s:{}", f.id),
|
||||
label: if active { format!("{} (editing)", f.name) } else { f.name.clone() },
|
||||
label: if lost {
|
||||
format!("{} (attachment lost)", f.name)
|
||||
} else if active {
|
||||
format!("{} (editing)", f.name)
|
||||
} else {
|
||||
f.name.clone()
|
||||
},
|
||||
icon: "sketch",
|
||||
visible: sketch_visible(app, f.id),
|
||||
selected: active || sel.iter().any(|x| matches!(x, Sel::Feature { id } if *id == f.id)),
|
||||
color: active.then_some(t.sketch_accent),
|
||||
color: if lost { Some(t.error) } else { active.then_some(t.sketch_accent) },
|
||||
locked: false,
|
||||
sel: Some(Sel::Feature { id: f.id }),
|
||||
target: Some(Target::Sketch { id: f.id }),
|
||||
@@ -1805,46 +1816,3 @@ fn occurrence_origin(app: &SolveApp, occurrence: u64) -> Option<solvecraft_engin
|
||||
let own = app.session.pending_moves.get(&occurrence).copied().unwrap_or(o.transform);
|
||||
Some(solvecraft_engine::doc::apply_point(&solvecraft_engine::doc::mat_mul(&parent, &own), solvecraft_engine::geom::Vec3::ZERO))
|
||||
}
|
||||
|
||||
/// Redefine Sketch Plane: pick an origin or construction plane, or the selected planar face.
|
||||
fn redefine_panel(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let Some(id) = app.tree.redefine else { return };
|
||||
let name = app.session.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let mut planes: Vec<String> = vec!["XY".into(), "XZ".into(), "YZ".into()];
|
||||
planes.extend(app.session.doc.features.iter().filter(|f| matches!(f.kind, FeatureKind::ConstructionPlane { .. })).map(|f| f.name.clone()));
|
||||
let face = app.session.selection.iter().find_map(|s| if let Sel::Face { point, .. } = s { Some(*point) } else { None });
|
||||
let mut open = true;
|
||||
let mut pick: Option<Value> = None;
|
||||
crate::frame::window(ctx, format!("Redefine Sketch Plane: {name}"), crate::frame::Width::Normal)
|
||||
.id(egui::Id::new("sc_redefine"))
|
||||
.pivot(egui::Align2::LEFT_TOP)
|
||||
.default_pos(panel_pos(app))
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.show(ctx, |ui| {
|
||||
ui.label(RichText::new("Move the sketch to").color(Tokens::get().text_dim));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for p in &planes {
|
||||
if ui.button(p).clicked() {
|
||||
pick = Some(json!(p));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
let b = ui.add_enabled(face.is_some(), egui::Button::new("Selected face"));
|
||||
if b.clicked()
|
||||
&& let Some(f) = face
|
||||
{
|
||||
pick = Some(json!({ "face": [f.x, f.y, f.z] }));
|
||||
}
|
||||
b.on_disabled_hover_text("Select a planar face in the viewport first");
|
||||
});
|
||||
if let Some(plane) = pick {
|
||||
if app.run("sketch.redefine", json!({ "sketch": id, "plane": plane })).is_ok() {
|
||||
app.tree.redefine = None;
|
||||
}
|
||||
} else if !open {
|
||||
app.tree.redefine = None;
|
||||
}
|
||||
}
|
||||
@@ -494,7 +494,10 @@ fn draw_revolve(
|
||||
if radius > 1e-9 {
|
||||
let e1 = (base - centre) / radius;
|
||||
let e2 = extent.rotation_axis.cross(e1);
|
||||
let handed = -direction.dot(extent.rotation_axis);
|
||||
let handed = match revolve_convention(d) {
|
||||
solvecraft_engine::doc::RevolveConvention::LegacyAxisStart => -direction.dot(extent.rotation_axis),
|
||||
solvecraft_engine::doc::RevolveConvention::SketchNormal => 1.0,
|
||||
};
|
||||
let at = |angle: f64| centre + (e1 * angle.cos() + e2 * angle.sin()) * radius;
|
||||
let points: Vec<_> = (0..=48).filter_map(|i| proj.to_screen(at(extent.display_start + extent.sweep * i as f64 / 48.0))).collect();
|
||||
painter.add(egui::Shape::line(points, Stroke::new(2.0, Tokens::get().manipulator)));
|
||||
@@ -608,6 +611,13 @@ fn draw_revolve(
|
||||
});
|
||||
}
|
||||
|
||||
fn revolve_convention(d: &Dialog) -> solvecraft_engine::doc::RevolveConvention {
|
||||
d.extra
|
||||
.get("convention")
|
||||
.and_then(|value| serde_json::from_value(value.clone()).ok())
|
||||
.unwrap_or(solvecraft_engine::doc::RevolveConvention::SketchNormal)
|
||||
}
|
||||
|
||||
fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Option<solvecraft_engine::doc::RevolveExtent> {
|
||||
let Sel::Profile { sketch, .. } = d.inputs.first()?.items.first()? else { return None };
|
||||
let state = app.session.world_state();
|
||||
@@ -639,7 +649,7 @@ fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Opt
|
||||
let other = d.extra.get("angle2").map(|value| app.session.doc.eval(value.as_str().unwrap_or_default(), ValueKind::Angle)).transpose().ok()?;
|
||||
(angle, other)
|
||||
};
|
||||
solvecraft_engine::doc::revolve_extent(
|
||||
solvecraft_engine::doc::revolve_extent_with_convention(
|
||||
&state,
|
||||
&plane,
|
||||
®ions,
|
||||
@@ -647,6 +657,7 @@ fn revolve_display_extent(app: &SolveApp, d: &Dialog, axis: (Vec3, Vec3)) -> Opt
|
||||
angles,
|
||||
d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"),
|
||||
to,
|
||||
revolve_convention(d),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
@@ -1330,7 +1330,30 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
placement_row(&mut d, ui);
|
||||
input_rows(&mut d, ui);
|
||||
match &mut d.kind {
|
||||
Kind::Sketch => {}
|
||||
Kind::Sketch => {
|
||||
if d.error.is_some()
|
||||
&& let Some(selection @ Sel::Face { .. }) = d.inputs.first().and_then(|i| i.items.first())
|
||||
{
|
||||
row_label(ui, "Attachment");
|
||||
ui.label("This face cannot provide a durable attachment. Choose another face or explicitly use a fixed plane.");
|
||||
ui.end_row();
|
||||
row_label(ui, "");
|
||||
if ui.button("Use fixed plane").clicked()
|
||||
&& let Some(mut plane) = crate::sketch_attachment_ui::face_plane(app, selection)
|
||||
{
|
||||
plane["fixed"] = json!(true);
|
||||
match app.run("sketch.create", json!({"plane":plane})) {
|
||||
Ok(_) => {
|
||||
app.pre_sketch_cam = Some(app.cam);
|
||||
look_at_sketch(app);
|
||||
cancel = true;
|
||||
}
|
||||
Err(error) => d.error = Some(error),
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
Kind::Extrude { distance, direction, operation, auto_op, distance2, taper, taper_present, to_offset, start, all } => {
|
||||
row_label(ui, "Start");
|
||||
let old = if object_on(&d.inputs, FROM_OBJECT) { 2 } else { usize::from(!start.is_empty()) };
|
||||
@@ -1931,10 +1954,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
}
|
||||
// A plane picked for a new sketch starts it right away.
|
||||
if matches!(d.kind, Kind::Sketch)
|
||||
&& d.error.is_none()
|
||||
&& let Some(sel) = d.inputs.first().and_then(|i| i.items.first()).cloned()
|
||||
{
|
||||
start_sketch(app, &sel);
|
||||
cancel = true;
|
||||
match start_sketch(app, &sel) {
|
||||
Ok(()) => cancel = true,
|
||||
Err(error) => d.error = Some(error),
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
match run_dialog(app, &d) {
|
||||
@@ -2154,25 +2180,25 @@ fn params_table(app: &mut SolveApp, ui: &mut egui::Ui, new_name: &mut String, ne
|
||||
}
|
||||
|
||||
/// Start a sketch on a picked plane or planar face and turn the view to face it.
|
||||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) {
|
||||
pub fn start_sketch(app: &mut SolveApp, sel: &Sel) -> Result<(), String> {
|
||||
let plane = match sel {
|
||||
Sel::Plane { name } => json!(name),
|
||||
Sel::Face { point, .. } => json!({"face": pt(*point)}),
|
||||
_ => return,
|
||||
Sel::Face { .. } => crate::sketch_attachment_ui::face_plane(app, sel).ok_or("Selected face placement no longer exists")?,
|
||||
_ => return Err("Select an origin plane, construction plane or planar body face".into()),
|
||||
};
|
||||
let before = app.cam;
|
||||
if app.run("sketch.create", json!({ "plane": plane })).is_ok() {
|
||||
app.pre_sketch_cam = Some(before);
|
||||
look_at_sketch(app);
|
||||
// On a face: look at the picked point, not the plane's origin (a corner of the face),
|
||||
// which left the face filling the view off-centre like a big square.
|
||||
if let Sel::Face { point, .. } = sel
|
||||
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
|
||||
{
|
||||
to.target = *point;
|
||||
app.animate_to(to);
|
||||
}
|
||||
app.run("sketch.create", json!({ "plane": plane }))?;
|
||||
app.pre_sketch_cam = Some(before);
|
||||
look_at_sketch(app);
|
||||
// On a face: look at the picked point, not the plane's origin (a corner of the face),
|
||||
// which left the face filling the view off-centre like a big square.
|
||||
if let Sel::Face { point, .. } = sel
|
||||
&& let Some(mut to) = app.cam_anim.as_ref().map(|a| a.to)
|
||||
{
|
||||
to.target = *point;
|
||||
app.animate_to(to);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Animate the camera to look straight at the active sketch plane.
|
||||
@@ -3060,7 +3086,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
}
|
||||
d
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, to } => {
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets, angle2, symmetric, convention, to } => {
|
||||
let mut d = start("solid.revolve")?;
|
||||
let frame = feature_frame(s, f.component);
|
||||
let stored_axis = match axis {
|
||||
@@ -3071,6 +3097,7 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
||||
_ => None,
|
||||
};
|
||||
d.kind = Kind::Revolve { angle: angle.clone(), operation: op_index(operation), axis: stored_axis };
|
||||
d.extra.insert("convention".into(), json!(convention));
|
||||
if let Some(a2) = angle2 {
|
||||
d.extra.insert("angle2".into(), json!(a2));
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ fn swap_in(app: &mut SolveApp, slot: DocSlot) -> DocSlot {
|
||||
app.preview = crate::preview::PreviewState::default();
|
||||
app.viewport.hover = None;
|
||||
app.menu.props = None;
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
let out_rev = app.session.revision;
|
||||
let out = DocSlot {
|
||||
session: std::mem::replace(&mut app.session, slot.session),
|
||||
@@ -142,6 +143,7 @@ pub fn adopt(app: &mut SolveApp, session: Session) {
|
||||
crate::dialogs::cancel(app);
|
||||
app.tool = None;
|
||||
app.menu.props = None;
|
||||
crate::sketch_attachment_ui::clear(app);
|
||||
app.session = session;
|
||||
app.session.invalidate_view(rev);
|
||||
app.cam = Camera::default();
|
||||
|
||||
@@ -75,6 +75,9 @@ pub mod shortcut_box;
|
||||
pub mod sketch3d;
|
||||
#[cfg(test)]
|
||||
mod sketch3d_frame_ui_tests;
|
||||
pub(crate) mod sketch_attachment_ui;
|
||||
#[cfg(test)]
|
||||
mod sketch_attachment_ui_tests;
|
||||
pub mod sketch_dims;
|
||||
#[cfg(test)]
|
||||
mod sketch_edit_tests;
|
||||
@@ -414,6 +417,7 @@ impl SolveApp {
|
||||
self.session.echo(format!("{} done", command_label(id)));
|
||||
if matches!(id, "file.new" | "doc.open" | "doc.recover") {
|
||||
self.menu.props = None;
|
||||
sketch_attachment_ui::clear(self);
|
||||
// The old document is already replaced: discard its editor without
|
||||
// applying cancellation hooks (such as restoring its timeline marker).
|
||||
self.dialog = None;
|
||||
@@ -574,6 +578,13 @@ impl SolveApp {
|
||||
/// puts them back. Editing the sketch that is already open does nothing; editing another one
|
||||
/// finishes the current sketch first, so edit sessions never nest.
|
||||
pub fn edit_sketch(&mut self, id: u64) {
|
||||
if let solvecraft_engine::doc::FaceAttachmentStatus::Lost { message, .. } =
|
||||
self.session.placed_document().sketch_face_attachment(id, &self.session.model)
|
||||
{
|
||||
browser::redefine_sketch(self, id);
|
||||
self.set_status(message, true);
|
||||
return;
|
||||
}
|
||||
if self.session.active_sketch == Some(id) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@ impl PreviewState {
|
||||
pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let cmds = match app.dialog.as_ref().filter(|d| d.previews()).map(|d| crate::dialogs::preview_commands(app, d)) {
|
||||
Some(Ok(c)) if !c.is_empty() => c,
|
||||
_ if crate::sketch_attachment_ui::commands(app).is_some() => crate::sketch_attachment_ui::commands(app).unwrap_or_default(),
|
||||
// A parameter being edited in Change Parameters.
|
||||
_ if app.params.as_ref().and_then(|p| p.preview_commands()).is_some() => {
|
||||
app.params.as_ref().and_then(|p| p.preview_commands()).unwrap_or_default()
|
||||
@@ -185,6 +186,15 @@ fn spawn(pv: &mut PreviewState, key: u64, scratch: Session, cmds: Vec<(String, V
|
||||
/// show their result in place of the bodies they change. When the feature fails (a boolean the
|
||||
/// kernel can't do yet), the tool body is still shown, with the error.
|
||||
pub fn compute(s: &Session, cmds: &[(String, Value)], colors: Colors) -> Job {
|
||||
if cmds.iter().any(|(command, _)| command == "sketch.redefine") {
|
||||
let preview = s.preview_visible_world(cmds).map_err(|error| error.to_string())?;
|
||||
return std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut built = build(&preview.before, &preview.after, colors);
|
||||
built.bounds = state_bounds(&preview.after);
|
||||
built
|
||||
}))
|
||||
.map_err(|_| "the preview failed".to_string());
|
||||
}
|
||||
// Joints and drives move occurrences: shown on the placed model.
|
||||
if let Some(job) = crate::dialogs_assembly::world_preview(s, cmds, colors) {
|
||||
return job;
|
||||
|
||||
@@ -51,15 +51,28 @@ fn to_object_noop_preserves_cut_targets_and_untargeted_body() {
|
||||
}
|
||||
let mut h = make_params(h, json!("Y"), json!({"to":[0,2,12],"angle":"45 deg","operation":"cut","targets":["A"]}));
|
||||
let state = h.app.session.world_state();
|
||||
let before_a = solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume;
|
||||
assert!((before_a - (2000.0 - 490.873852)).abs() < 0.2);
|
||||
// A quarter annulus separates the stock into inner and outer solids. Both are
|
||||
// results of cutting A; checking only the first named solid loses material.
|
||||
let cut_volume = |state: &solvecraft_engine::doc::ModelState| -> f64 {
|
||||
state.bodies.iter().filter(|b| b.name != "B").map(|b| solvecraft_engine::doc::kernel::measure(&b.body).unwrap().volume).sum()
|
||||
};
|
||||
let before_a = cut_volume(&state);
|
||||
let removed = 0.5 * (15.0f64.powi(2) - 10.0f64.powi(2)) * 5.0 * std::f64::consts::FRAC_PI_2;
|
||||
assert!((before_a - (2000.0 - removed)).abs() < 0.2, "cut results {:?}: {before_a}", state.bodies.iter().map(|b| &b.name).collect::<Vec<_>>());
|
||||
let in_cut_stock = |p| state.bodies.iter().filter(|b| b.name != "B").any(|b| b.body.contains(p));
|
||||
assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(2.0, 2.0, 2.0)));
|
||||
assert!(in_cut_stock(solvecraft_engine::geom::Vec3::new(18.0, 2.0, 18.0)));
|
||||
assert!(!in_cut_stock(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0)));
|
||||
assert!(state.body("B").unwrap().body.contains(solvecraft_engine::geom::Vec3::new(9.0, 2.0, 9.0)));
|
||||
let before_bounds: Vec<_> = state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
|
||||
let undo = h.app.session.undo.len();
|
||||
open(&mut h);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert!(h.app.dialog.is_none());
|
||||
let state = h.app.session.world_state();
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("B").unwrap().body).unwrap().volume - 2000.0).abs() < 1e-7);
|
||||
assert!((solvecraft_engine::doc::kernel::measure(&state.body("A").unwrap().body).unwrap().volume - before_a).abs() < 1e-7);
|
||||
assert!((cut_volume(&state) - before_a).abs() < 1e-7);
|
||||
assert_eq!(state.bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect::<Vec<_>>(), before_bounds);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let FeatureKind::Revolve { targets, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(targets, &["A"]);
|
||||
|
||||
@@ -251,7 +251,21 @@ fn edge_on_ring_drag_uses_nearest_projected_branch() {
|
||||
|
||||
#[test]
|
||||
fn legacy_reversed_axis_symmetric_interval_is_shown_and_preserved_exactly() {
|
||||
let mut h = fixture(Harness::new(), json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"direction":"symmetric","angle":"120 deg"}));
|
||||
let mut h = fixture(
|
||||
Harness::new(),
|
||||
json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"direction":"symmetric","angle":"120 deg","convention":"legacy_axis_start"}),
|
||||
);
|
||||
// Exercise an actual older design: no convention marker was stored by version 10.
|
||||
let mut saved: Value = serde_json::from_str(&h.app.session.doc.to_json()).unwrap();
|
||||
saved["format"] = json!("solvecraft/10");
|
||||
for feature in saved["features"].as_array_mut().unwrap() {
|
||||
feature.as_object_mut().unwrap().remove("convention");
|
||||
}
|
||||
let path = h.dir.join("legacy-revolve.solvecraft");
|
||||
std::fs::write(&path, serde_json::to_vec(&saved).unwrap()).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::LegacyAxisStart);
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
assert!(before.min.x < -14.9 && before.max.x < 7.6 && before.min.z > -1e-6 && before.max.z > 14.9);
|
||||
@@ -270,3 +284,83 @@ fn legacy_reversed_axis_symmetric_interval_is_shown_and_preserved_exactly() {
|
||||
assert!(after.min.z > 7.0 && after.max.z > 14.9);
|
||||
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_symmetric_ring_preview_drag_and_reopen_match_material() {
|
||||
for axis in [json!("Y"), json!({"origin":[0,0,0],"dir":[0,-2,0]})] {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[2,0],"p1":[4,10]})).unwrap();
|
||||
h.app.run("sketch.finish", json!({})).unwrap();
|
||||
h.app
|
||||
.run("solid.revolve", json!({"sketch":"Profile","axis":axis,"angle":"90 deg","direction":"symmetric","name":"Turn","body_name":"Main"}))
|
||||
.unwrap();
|
||||
let before = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
open(&mut h);
|
||||
let at = |degrees: f64| {
|
||||
let a = degrees.to_radians();
|
||||
json!([3.0 * a.cos(), 5.0, 3.0 * a.sin()])
|
||||
};
|
||||
handle_at(&mut h, "revolve", at(45.0));
|
||||
handle_at(&mut h, "revolve2", at(-45.0));
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let body = &preview.after.body("Main").unwrap().body;
|
||||
assert!(body.contains(solvecraft_engine::geom::Vec3::new(3.0, 5.0, 0.0)));
|
||||
assert!(!body.contains(solvecraft_engine::geom::Vec3::new(0.0, 5.0, 3.0)));
|
||||
let bounds = preview.after.body("Main").unwrap().mesh().bounds();
|
||||
assert!((bounds.min.x - 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.x - 4.0).abs() < 0.02, "{bounds:?}");
|
||||
assert!((bounds.min.z + 2.0 * 2.0f64.sqrt()).abs() < 0.02 && (bounds.max.z - 2.0 * 2.0f64.sqrt()).abs() < 0.02);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), before);
|
||||
open(&mut h);
|
||||
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at(-30.0)}]})).unwrap();
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(angle(&h), "60 deg");
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
assert!((volume(&h) - 60.0 * std::f64::consts::FRAC_PI_3).abs() < 0.03);
|
||||
let path = h.dir.join("canonical-revolve.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
open(&mut h);
|
||||
handle_at(&mut h, "revolve", at(30.0));
|
||||
handle_at(&mut h, "revolve2", at(-30.0));
|
||||
let FeatureKind::Revolve { convention, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
|
||||
assert_eq!(*convention, solvecraft_engine::doc::RevolveConvention::SketchNormal);
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reversed_axis_to_object_canvas_sweep_and_preview_follow_the_quarter_target() {
|
||||
let mut h = fixture(Harness::new(), json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]},"to":[0,2,12],"angle":"45 deg"}));
|
||||
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
|
||||
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
|
||||
let before = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
|
||||
let p = 12.5 / 2.0f64.sqrt();
|
||||
open(&mut h);
|
||||
assert!(h.widget("Sweep 90.0°").is_some());
|
||||
assert!(crate::scenario::handle_at("revolve").is_none());
|
||||
assert!(crate::scenario::handle_at("revolve2").is_none());
|
||||
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
|
||||
let preview = h.app.session.preview_world(&commands).unwrap();
|
||||
let result = preview.after.body("Main").unwrap();
|
||||
assert_eq!(result.mesh().bounds(), before);
|
||||
assert!(result.body.contains(solvecraft_engine::geom::Vec3::new(p, 2.5, p)));
|
||||
for (x, z) in [(-p, p), (-p, -p), (p, -p)] {
|
||||
assert!(!result.body.contains(solvecraft_engine::geom::Vec3::new(x, 2.5, z)));
|
||||
}
|
||||
assert!((kernel::measure(&result.body).unwrap().volume - expected_volume(90.0)).abs() < 0.03);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
|
||||
let path = h.dir.join("reversed-to-object.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
open(&mut h);
|
||||
assert!(h.widget("Sweep 90.0°").is_some());
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
|
||||
assert_eq!(h.app.session.world_state().body("Main").unwrap().mesh().bounds(), before);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! Reviewable sketch-plane rebinding. Saved lost planes are repair guides, never model geometry.
|
||||
|
||||
use egui::{Pos2, RichText};
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::Sel;
|
||||
use solvecraft_engine::doc::face_anchor::plane_face_anchor;
|
||||
use solvecraft_engine::doc::{EmbossFace, FaceAttachmentStatus, FeatureKind, mat_inverse};
|
||||
use solvecraft_engine::geom::{Plane, Vec3};
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::theme::Tokens;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Draft {
|
||||
pub sketch: u64,
|
||||
pub candidate: Option<Value>,
|
||||
pub label: String,
|
||||
pub error: Option<String>,
|
||||
pub author_path: Option<Vec<u64>>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
pub fn new(app: &SolveApp, sketch: u64) -> Self {
|
||||
let author_path = app.session.doc.feature(sketch).and_then(|feature| match &feature.kind {
|
||||
FeatureKind::Sketch { plane, .. } if plane_face_anchor(plane).is_some() => {
|
||||
plane_face_anchor(plane).map(|anchor| anchor.author_path.clone())
|
||||
}
|
||||
_ if feature.component == app.session.active_component => app.session.active_occurrence_path.clone(),
|
||||
_ => None,
|
||||
});
|
||||
Self { sketch, candidate: None, label: String::new(), error: None, author_path }
|
||||
}
|
||||
|
||||
fn choose(&mut self, plane: Value, label: String) {
|
||||
self.candidate = Some(plane);
|
||||
self.label = label;
|
||||
self.error = None;
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Option<Vec<(String, Value)>> {
|
||||
let mut params = json!({"sketch":self.sketch,"plane":self.candidate.as_ref()?});
|
||||
if let Some(path) = &self.author_path {
|
||||
params["author_path"] = json!(path);
|
||||
}
|
||||
Some(vec![("sketch.redefine".into(), params)])
|
||||
}
|
||||
|
||||
pub fn choose_saved_fixed(&mut self, app: &SolveApp, world: Plane) -> Result<(), String> {
|
||||
let doc = app.session.placed_document();
|
||||
let owner = doc.feature(self.sketch).ok_or("Sketch no longer exists")?.component;
|
||||
let path = self.author_path.clone().unwrap_or_else(|| doc.first_path(owner));
|
||||
if self.author_path.is_none()
|
||||
&& path.iter().any(|id| {
|
||||
doc.occurrences
|
||||
.iter()
|
||||
.find(|o| o.id == *id)
|
||||
.is_none_or(|selected| doc.occurrences.iter().filter(|o| o.component == selected.component).count() != 1)
|
||||
})
|
||||
{
|
||||
return Err("Select an exact author placement before converting this plane".into());
|
||||
}
|
||||
let frame = EmbossFace::component_frame(&doc, owner, &path).map_err(|error| error.to_string())?;
|
||||
mat_inverse(&frame).ok_or("Sketch author frame is invalid")?;
|
||||
self.author_path = Some(path);
|
||||
// Public commands take world geometry. Session maps Custom planes into
|
||||
// this exact author placement once, including redefine's saved path.
|
||||
self.choose(fixed_plane(world), "Fixed plane: independent of the source body".into());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(app: &mut SolveApp) {
|
||||
app.tree.redefine = None;
|
||||
app.tree.redefine_draft = None;
|
||||
}
|
||||
|
||||
pub fn commands(app: &SolveApp) -> Option<Vec<(String, Value)>> {
|
||||
let draft = app.tree.redefine_draft.as_ref()?;
|
||||
(app.tree.redefine == Some(draft.sketch)).then(|| draft.commands()).flatten()
|
||||
}
|
||||
|
||||
fn attachment(app: &SolveApp, sketch: u64) -> FaceAttachmentStatus {
|
||||
app.session.placed_document().sketch_face_attachment(sketch, &app.session.model)
|
||||
}
|
||||
|
||||
fn fixed_plane(plane: Plane) -> Value {
|
||||
json!({"origin":plane.origin,"x_dir":plane.x,"y_dir":plane.y})
|
||||
}
|
||||
|
||||
pub fn face_plane(app: &SolveApp, selection: &Sel) -> Option<Value> {
|
||||
match selection {
|
||||
Sel::Face { body, index, point } => {
|
||||
let doc = app.session.placed_document();
|
||||
let (_, _, _, path, _) = doc.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)?;
|
||||
Some(json!({"face":point,"body":body,"index":index,"path":path}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn face_label(app: &SolveApp, selection: &Sel) -> String {
|
||||
let Sel::Face { body, .. } = selection else { return String::new() };
|
||||
let placed = app.session.placed_document();
|
||||
if let Some((_, source, _, path, _)) =
|
||||
placed.body_frames_with_source(&app.session.model.state()).into_iter().find(|(world, _, _, _, _)| world == body)
|
||||
{
|
||||
format!("Face on {source} / {}", path_label(app, &path))
|
||||
} else {
|
||||
format!("Face on {body}")
|
||||
}
|
||||
}
|
||||
|
||||
fn path_label(app: &SolveApp, path: &[u64]) -> String {
|
||||
if path.is_empty() {
|
||||
return "Root".into();
|
||||
}
|
||||
(1..=path.len())
|
||||
.map(|end| app.session.occurrence_placement(&path[..end]).map(|placement| placement.name).unwrap_or_else(|_| "Missing occurrence".into()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
}
|
||||
|
||||
pub fn panel(app: &mut SolveApp, ctx: &egui::Context, at: Pos2) {
|
||||
let Some(sketch) = app.tree.redefine else { return };
|
||||
if app.session.doc.feature(sketch).is_none() {
|
||||
clear(app);
|
||||
return;
|
||||
}
|
||||
let mut draft = app.tree.redefine_draft.take().filter(|draft| draft.sketch == sketch).unwrap_or_else(|| Draft::new(app, sketch));
|
||||
let status = attachment(app, sketch);
|
||||
let name = app.session.doc.feature(sketch).map(|feature| feature.name.as_str()).unwrap_or("Sketch");
|
||||
let selected = app.session.selection.iter().find(|selection| matches!(selection, Sel::Face { .. })).cloned();
|
||||
let mut open = true;
|
||||
let mut apply = false;
|
||||
let mut cancel = false;
|
||||
let t = Tokens::get();
|
||||
crate::frame::docked(
|
||||
format!("Redefine Sketch Plane: {name}"),
|
||||
egui::Id::new("sc_redefine"),
|
||||
at,
|
||||
app.viewport.rect.map(|rect| rect.bottom()).unwrap_or_else(|| ctx.content_rect().bottom()) - at.y - 8.0,
|
||||
crate::frame::Width::Normal,
|
||||
)
|
||||
.open(&mut open)
|
||||
.scroll([false, true])
|
||||
.show(ctx, |ui| {
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.strong(format!("Redefine Sketch Plane: {name}"));
|
||||
ui.separator();
|
||||
let saved_plane = match &status {
|
||||
FaceAttachmentStatus::Healthy { plane, target, path } => {
|
||||
ui.label(format!("Attached to {} / {}", target.body, path_label(app, path)));
|
||||
Some(*plane)
|
||||
}
|
||||
FaceAttachmentStatus::Lost { message, repair_plane } => {
|
||||
ui.label(RichText::new("Face attachment lost").color(t.error));
|
||||
ui.label(message);
|
||||
if repair_plane.is_some() {
|
||||
ui.label("The dashed sketch is a saved plane guide. Select a new face to repair the attachment.");
|
||||
} else {
|
||||
ui.label("The saved author occurrence is missing. Select a valid author placement before rebinding.");
|
||||
}
|
||||
*repair_plane
|
||||
}
|
||||
FaceAttachmentStatus::Legacy { plane } => {
|
||||
ui.label("Legacy face attachment. Rebinding to a certified face creates an exact attachment.");
|
||||
*plane
|
||||
}
|
||||
FaceAttachmentStatus::Fixed { plane } => {
|
||||
ui.label("Sketch plane has no body-face attachment.");
|
||||
*plane
|
||||
}
|
||||
};
|
||||
ui.separator();
|
||||
if matches!(&status, FaceAttachmentStatus::Lost { repair_plane: None, .. }) {
|
||||
let author = app.session.doc.feature(sketch).map(|feature| feature.component);
|
||||
let available = author == Some(app.session.active_component);
|
||||
if ui.add_enabled(available, egui::Button::new("Use active author placement")).clicked() {
|
||||
draft.author_path = Some(app.session.active_occurrence_path.clone().unwrap_or_default());
|
||||
draft.error = None;
|
||||
}
|
||||
if available && let Some(path) = &draft.author_path {
|
||||
ui.label(format!("Author placement: {}", path_label(app, path)));
|
||||
}
|
||||
}
|
||||
ui.label("Choose a new plane. Sketch geometry and dimensions stay unchanged in its two-dimensional coordinates.");
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for plane in ["XY", "XZ", "YZ"] {
|
||||
if ui.button(format!("Use {plane} plane")).clicked() {
|
||||
draft.choose(json!(plane), plane.into());
|
||||
}
|
||||
}
|
||||
});
|
||||
for feature in &app.session.doc.features {
|
||||
if matches!(feature.kind, FeatureKind::ConstructionPlane { .. }) && ui.button(&feature.name).clicked() {
|
||||
draft.choose(json!(feature.name), feature.name.clone());
|
||||
}
|
||||
}
|
||||
if ui.add_enabled(selected.is_some(), egui::Button::new("Reattach selected face")).clicked()
|
||||
&& let Some(selection @ Sel::Face { .. }) = &selected
|
||||
&& let Some(plane) = face_plane(app, selection)
|
||||
{
|
||||
let label = face_label(app, selection);
|
||||
draft.choose(plane, label);
|
||||
}
|
||||
if ui.add_enabled(saved_plane.is_some(), egui::Button::new("Use saved fixed plane")).clicked()
|
||||
&& let Some(plane) = saved_plane
|
||||
&& let Err(error) = draft.choose_saved_fixed(app, plane)
|
||||
{
|
||||
draft.error = Some(error);
|
||||
}
|
||||
if ui.add_enabled(selected.is_some(), egui::Button::new("Use selected face as fixed plane")).clicked()
|
||||
&& let Some(selection) = &selected
|
||||
&& let Some(mut plane) = face_plane(app, selection)
|
||||
{
|
||||
plane["fixed"] = json!(true);
|
||||
draft.choose(plane, "Fixed plane: independent of the selected body".into());
|
||||
}
|
||||
if draft.candidate.is_some() {
|
||||
ui.separator();
|
||||
ui.label(format!("Preview: {}", draft.label));
|
||||
}
|
||||
if let Some(error) = draft.error.as_ref().or(app.preview.error.as_ref()) {
|
||||
ui.label(RichText::new(error).color(t.error));
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.add_enabled(draft.candidate.is_some() && app.preview.error.is_none(), egui::Button::new("Apply")).clicked() {
|
||||
apply = true;
|
||||
}
|
||||
if ui.button("Cancel").clicked() {
|
||||
cancel = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
if ctx.input(|input| input.key_pressed(egui::Key::Escape)) {
|
||||
cancel = true;
|
||||
app.esc_handled = true;
|
||||
}
|
||||
if apply && let Some(commands) = draft.commands() {
|
||||
for (command, params) in commands {
|
||||
match app.run(&command, params) {
|
||||
Ok(_) => cancel = true,
|
||||
Err(error) => draft.error = Some(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
if cancel || !open {
|
||||
clear(app);
|
||||
} else {
|
||||
app.tree.redefine_draft = Some(draft);
|
||||
}
|
||||
}
|
||||
|
||||
/// Only a lost reference's explicit saved author frame supplies a guide. It is
|
||||
/// absent from ModelState, selection, snapping and all downstream evaluation.
|
||||
pub fn lost_guide(app: &SolveApp) -> Option<(Plane, &solvecraft_engine::sketch::Sketch)> {
|
||||
let sketch = app.tree.redefine?;
|
||||
let FaceAttachmentStatus::Lost { repair_plane: Some(plane), .. } = attachment(app, sketch) else { return None };
|
||||
let FeatureKind::Sketch { sketch, .. } = &app.session.doc.feature(sketch)?.kind else { return None };
|
||||
Some((plane, sketch))
|
||||
}
|
||||
|
||||
pub fn guide_lines(app: &SolveApp) -> Vec<Vec<Vec3>> {
|
||||
let Some((plane, sketch)) = lost_guide(app) else { return Vec::new() };
|
||||
(0..sketch.curves.len())
|
||||
.flat_map(|index| sketch.segs(index))
|
||||
.map(|segment| segment.polyline(0.05).into_iter().map(|point| plane.to_world(point)).collect())
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
use crate::scenario::Harness;
|
||||
use serde_json::{Value, json};
|
||||
use solvecraft_engine::doc::{FaceAttachmentStatus, FeatureKind, PlaneRef};
|
||||
|
||||
fn picked_sketch() -> (Harness, u64) {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.session.auto_project = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"BaseBox","name":"Original"})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"corner":[0,0,20],"body_name":"ControlBox","name":"Control"})).unwrap();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[3,4,10]}})).unwrap();
|
||||
h.settle();
|
||||
let sketch = h.app.session.active_sketch.expect("actual picked face creates the sketch");
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &h.app.session.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("picked face must create a certified attachment");
|
||||
};
|
||||
assert_eq!(anchor.target.body, "BaseBox");
|
||||
assert!(anchor.path.is_empty());
|
||||
h.app.run("parameters.add", json!({"name":"badge_diameter","expression":"2 mm"})).unwrap();
|
||||
h.step(&json!({"start":"sketch.circle.center"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
h.step(&json!({"move":{"world":[7,5,10]}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"badge_diameter"})).unwrap();
|
||||
h.step(&json!({"key":"Enter"})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap();
|
||||
assert!(!h.app.session.doc.sketch(sketch).unwrap().constraints.is_empty());
|
||||
(h, sketch)
|
||||
}
|
||||
|
||||
fn raw_sketch(h: &Harness, id: u64) -> Value {
|
||||
serde_json::to_value(h.app.session.doc.sketch(id).unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_picked_face_and_noop_edit_preserve_exact_anchor_and_dimensions() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
let before = serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap();
|
||||
h.app.edit_sketch(sketch);
|
||||
h.settle();
|
||||
h.app.finish_sketch();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before);
|
||||
let path = h.dir.join("picked-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(serde_json::to_value(&h.app.session.doc.feature(sketch).unwrap().kind).unwrap(), before);
|
||||
assert!(matches!(h.app.session.doc.sketch_face_attachment(sketch, &h.app.session.model), FaceAttachmentStatus::Healthy { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lost_attachment_ghost_rebind_preview_cancel_apply_and_undo_preserve_sketch() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
h.app.run("solid.extrude", json!({"sketch":sketch,"distance":2,"operation":"new_body","body_name":"Boss"})).unwrap();
|
||||
h.app.run("timeline.suppress", json!({"feature":"Original","suppressed":true})).unwrap();
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_some());
|
||||
assert!(h.app.session.world_state().body("Boss").is_none(), "lost attachment cannot produce a Boss on ControlBox");
|
||||
assert!(h.app.session.world_state().sketch(sketch).is_none(), "saved repair guides are not solved sketch geometry");
|
||||
let raw = raw_sketch(&h, sketch);
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let redo = h.app.session.redo.len();
|
||||
h.app.edit_sketch(sketch);
|
||||
h.frames(4);
|
||||
assert_eq!(h.app.tree.redefine, Some(sketch));
|
||||
assert!(h.widget("Face attachment lost").is_some());
|
||||
let guides = crate::sketch_attachment_ui::guide_lines(&h.app);
|
||||
assert!(!guides.is_empty());
|
||||
assert!(guides.iter().flatten().all(|point| (point.z - 10.0).abs() < 1e-8));
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":false})).unwrap();
|
||||
h.app.session.selection.clear();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
assert!(h.app.session.selection.is_empty(), "saved lost-plane guides must not be selectable geometry");
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
h.app.run("browser.visibility", json!({"items":["ControlBox"],"visible":true})).unwrap();
|
||||
h.frames(4);
|
||||
h.step(&json!({"click":{"world":[3,4,30]}})).unwrap();
|
||||
assert!(matches!(h.app.session.selection.first(), Some(solvecraft_engine::Sel::Face { body, .. }) if body=="ControlBox"));
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"Reattach selected face"})).unwrap();
|
||||
let commands = crate::sketch_attachment_ui::commands(&h.app)
|
||||
.unwrap_or_else(|| panic!("repair draft {:?}, selection {:?}", h.app.tree.redefine_draft, h.app.session.selection));
|
||||
assert_eq!(commands[0].1["plane"]["body"], "ControlBox");
|
||||
assert!(commands[0].1["plane"]["index"].as_u64().is_some());
|
||||
let preview = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
let boss = preview.after.body("Boss").unwrap().mesh().bounds();
|
||||
assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7);
|
||||
assert_eq!(h.app.session.doc.to_json(), before, "preview is scratch only");
|
||||
h.step(&json!({"widget":"Cancel"})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.session.redo.len(), redo);
|
||||
assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty());
|
||||
|
||||
crate::browser::redefine_sketch(&mut h.app, sketch);
|
||||
h.frames(4);
|
||||
h.step(&json!({"widget":"Reattach selected face"})).unwrap();
|
||||
h.frames(8);
|
||||
h.step(&json!({"widget":"Apply"})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none(), "{:?}", h.app.tree.redefine_draft);
|
||||
assert_eq!(raw_sketch(&h, sketch), raw, "curves, dimensions and expressions remain exact");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
|
||||
let boss = h.app.session.world_state().body("Boss").unwrap().mesh().bounds();
|
||||
assert!((boss.min.z - 30.0).abs() < 1e-7 && (boss.max.z - 32.0).abs() < 1e-7);
|
||||
let repaired = h.app.session.doc.to_json();
|
||||
let path = h.dir.join("rebound-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":path})).unwrap();
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.app.run("doc.open", json!({"path":path})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), repaired);
|
||||
assert_eq!(raw_sketch(&h, sketch), raw);
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_drafts_leave_on_document_replacement_but_survive_failed_open() {
|
||||
let (mut h, sketch) = picked_sketch();
|
||||
crate::browser::redefine_sketch(&mut h.app, sketch);
|
||||
h.frames(2);
|
||||
h.step(&json!({"widget":"Use XZ plane"})).unwrap();
|
||||
assert!(crate::sketch_attachment_ui::commands(&h.app).is_some());
|
||||
assert!(h.app.run("doc.open", json!({"path":h.dir.join("missing.solvecraft")})).is_err());
|
||||
assert!(crate::sketch_attachment_ui::commands(&h.app).is_some());
|
||||
h.app.run("file.new", json!({})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none());
|
||||
assert!(h.app.tree.redefine_draft.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_modified_face_offers_explicit_fixed_plane_without_live_links() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Modified"})).unwrap();
|
||||
h.app.run("solid.fillet", json!({"edges":[[0,0,5]],"radius":1})).unwrap();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[5,5,10]}})).unwrap();
|
||||
assert!(h.app.session.active_sketch.is_none());
|
||||
assert!(h.app.dialog.as_ref().unwrap().error.is_some());
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
h.frames(4);
|
||||
assert!(h.widget("Use fixed plane").is_some());
|
||||
h.step(&json!({"widget":"Use fixed plane"})).unwrap();
|
||||
let sketch = h.app.session.active_sketch.expect("explicit fixed choice succeeds for the actual planar surface");
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Custom { plane }, sketch: geometry } = &h.app.session.doc.feature(sketch).unwrap().kind else {
|
||||
panic!("fixed choice must never create a durable-looking Face reference");
|
||||
};
|
||||
assert!((plane.origin.z - 10.0).abs() < 1e-7);
|
||||
assert!(geometry.links.is_empty(), "a fixed snapshot cannot carry live source projections");
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
}
|
||||
|
||||
fn occurrence_click(h: &mut Harness, path: &[u64], control: &str) {
|
||||
h.step(&json!({"click":{"occurrence":path,"control":control}})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn actual_nested_second_placement_face_pick_keeps_target_and_author_paths() {
|
||||
let mut h = Harness::new();
|
||||
h.app.ui.show_origin = false;
|
||||
h.app.session.auto_project = false;
|
||||
let parent = h.app.run("component.create", json!({"name":"Parent"})).unwrap();
|
||||
let parent_component = parent["component"].as_u64().unwrap();
|
||||
let first_parent = parent["occurrence"].as_u64().unwrap();
|
||||
let child = h.app.run("component.create", json!({"name":"Child","parent":parent_component,"parent_path":[first_parent]})).unwrap();
|
||||
let child_component = child["component"].as_u64().unwrap();
|
||||
let child_occurrence = child["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"NestedBase"})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
let second_parent =
|
||||
h.app.run("occurrence.copy", json!({"occurrence":first_parent,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.frames(4);
|
||||
let path = vec![second_parent, child_occurrence];
|
||||
if !crate::browser::drawn_occurrences().iter().any(|(drawn, _, _, _)| drawn == &path) {
|
||||
occurrence_click(&mut h, &[second_parent], "fold");
|
||||
}
|
||||
occurrence_click(&mut h, &path, "activate");
|
||||
assert_eq!(h.app.session.active_component, child_component);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path));
|
||||
h.call("ui.view", json!({"view":"top"}));
|
||||
h.call("ui.view", json!({"view":"fit"}));
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.create"})).unwrap();
|
||||
h.step(&json!({"click":{"world":[43,4,10]}})).unwrap();
|
||||
let sketch = h.app.session.active_sketch.expect("actual second nested face pick");
|
||||
let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone();
|
||||
let FeatureKind::Sketch { plane: PlaneRef::Face { anchor: Some(anchor), .. }, .. } = &kind else {
|
||||
panic!("strict face attachment");
|
||||
};
|
||||
assert_eq!(anchor.target.body, "NestedBase");
|
||||
assert_eq!(anchor.path, path);
|
||||
assert_eq!(anchor.author_path, path);
|
||||
assert_eq!(anchor.component, child_component);
|
||||
let FaceAttachmentStatus::Healthy { plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else {
|
||||
panic!("selected saved placement resolves");
|
||||
};
|
||||
assert!((plane.origin.x - 40.0).abs() < 1e-7 && (plane.origin.z - 10.0).abs() < 1e-7);
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap();
|
||||
h.app.run("sketch.dimension", json!({"entities":["l1"],"value":2})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
let kind = h.app.session.doc.feature(sketch).unwrap().kind.clone();
|
||||
h.app.run("component.activate", json!({"component":0})).unwrap();
|
||||
h.app.edit_sketch(sketch);
|
||||
assert_eq!(h.app.session.active_component, child_component);
|
||||
assert_eq!(h.app.session.active_occurrence_path.as_ref(), Some(&path));
|
||||
h.app.finish_sketch();
|
||||
assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind);
|
||||
let file = h.dir.join("nested-face.solvecraft");
|
||||
h.app.run("file.save", json!({"path":file})).unwrap();
|
||||
h.app.run("doc.open", json!({"path":file})).unwrap();
|
||||
assert_eq!(h.app.session.doc.feature(sketch).unwrap().kind, kind);
|
||||
h.app.run("selection.delete", json!({"occurrences":[second_parent]})).unwrap();
|
||||
assert!(h.app.session.model.result(sketch).unwrap().error.is_some());
|
||||
let FaceAttachmentStatus::Lost { repair_plane, .. } = h.app.session.placed_document().sketch_face_attachment(sketch, &h.app.session.model) else {
|
||||
panic!("deleted second placement must not substitute first");
|
||||
};
|
||||
assert!(repair_plane.is_none(), "a missing saved author placement cannot be drawn at its sibling");
|
||||
h.app.edit_sketch(sketch);
|
||||
h.frames(4);
|
||||
assert!(crate::sketch_attachment_ui::guide_lines(&h.app).is_empty());
|
||||
assert_eq!(h.app.tree.redefine, Some(sketch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_anchor_saved_fixed_conversion_preserves_rotated_second_author_plane() {
|
||||
use solvecraft_engine::doc::{EmbossFace, apply_point, apply_vector};
|
||||
use solvecraft_engine::geom::Plane;
|
||||
use solvecraft_engine::geom::Vec3;
|
||||
let mut h = Harness::new();
|
||||
h.app.session.auto_project = false;
|
||||
let created = h.app.run("component.create", json!({"name":"Author"})).unwrap();
|
||||
let component = created["component"].as_u64().unwrap();
|
||||
let first = created["occurrence"].as_u64().unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Base"})).unwrap();
|
||||
let second = h.app.run("occurrence.copy", json!({"occurrence":first,"translate":[40,0,0]})).unwrap()["occurrence"].as_u64().unwrap();
|
||||
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,1,0],"angle":"90 deg","capture":true})).unwrap();
|
||||
h.app.run("component.activate", json!({"component":component,"path":[second],"occurrence":second})).unwrap();
|
||||
let doc = h.app.session.placed_document();
|
||||
let body_name = doc.body_frames_with_source(&h.app.session.model.state()).into_iter().find(|(_, _, _, path, _)| path == &[second]).unwrap().0;
|
||||
let world = h.app.session.world_state();
|
||||
let face = world
|
||||
.body(&body_name)
|
||||
.unwrap()
|
||||
.body
|
||||
.faces(0.01)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|face| world.body(&body_name).unwrap().body.face_plane(face.index).is_some_and(|plane| plane.normal().dot(Vec3::X) > 0.999))
|
||||
.unwrap();
|
||||
let id = h
|
||||
.app
|
||||
.run("sketch.create", json!({"plane":{"body":body_name,"index":face.index,"face":face.centroid,"path":[second]},"project_edges":false}))
|
||||
.unwrap()["sketch"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[1,1],"p1":[3,3]})).unwrap();
|
||||
h.app.finish_sketch();
|
||||
// Exercise a durable anchor under both public plane wrappers, as imported
|
||||
// documents can contain these without an interactive wrapper editor.
|
||||
let mut document = (*h.app.session.doc).clone();
|
||||
let FeatureKind::Sketch { plane, .. } = &mut document.feature_mut(id).unwrap().kind else {
|
||||
panic!("sketch");
|
||||
};
|
||||
*plane = PlaneRef::AtAngle {
|
||||
base: Box::new(PlaneRef::Offset { base: Box::new(plane.clone()), distance: "2 mm".into() }),
|
||||
axis_origin: Vec3::ZERO,
|
||||
axis_dir: Vec3::X,
|
||||
angle: "15 deg".into(),
|
||||
};
|
||||
h.app.session = solvecraft_engine::Session::new(document);
|
||||
let raw = h.app.session.doc.sketch(id).unwrap().clone();
|
||||
let before = h.app.session.doc.to_json();
|
||||
let undo = h.app.session.undo.len();
|
||||
let FaceAttachmentStatus::Healthy { plane: expected, .. } = h.app.session.placed_document().sketch_face_attachment(id, &h.app.session.model)
|
||||
else {
|
||||
panic!("wrapped anchor must resolve");
|
||||
};
|
||||
crate::browser::redefine_sketch(&mut h.app, id);
|
||||
h.frames(4);
|
||||
assert_eq!(h.app.tree.redefine_draft.as_ref().unwrap().author_path, Some(vec![second]));
|
||||
h.step(&json!({"widget":"Use saved fixed plane"})).unwrap();
|
||||
let commands = crate::sketch_attachment_ui::commands(&h.app).unwrap();
|
||||
let preview = h.app.session.preview_visible_world(&commands).unwrap();
|
||||
// The shared sketch's primary world entry belongs to the untransformed
|
||||
// first placement. Verify that local plane at the specifically chosen
|
||||
// second placement, rather than treating the primary entry as that path.
|
||||
let frame = EmbossFace::component_frame(&h.app.session.placed_document(), component, &[second]).unwrap();
|
||||
let selected_plane =
|
||||
|local: Plane| Plane::new(apply_point(&frame, local.origin), apply_vector(&frame, local.x), apply_vector(&frame, local.y)).unwrap();
|
||||
let shown = selected_plane(preview.after.sketch(id).unwrap().plane);
|
||||
assert!(
|
||||
shown.origin.dist(expected.origin) < 1e-7,
|
||||
"expected {expected:?}, shown {shown:?}, primary {:?}, commands {commands:?}",
|
||||
preview.after.sketch(id).unwrap().plane
|
||||
);
|
||||
assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7);
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
h.frames(6);
|
||||
h.step(&json!({"widget":"Apply"})).unwrap();
|
||||
assert!(h.app.tree.redefine.is_none());
|
||||
assert_eq!(h.app.session.doc.sketch(id).unwrap(), &raw);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let shown = selected_plane(h.app.session.model.state().sketch(id).unwrap().plane);
|
||||
assert!(shown.origin.dist(expected.origin) < 1e-7);
|
||||
assert!(shown.x.dist(expected.x) < 1e-7 && shown.y.dist(expected.y) < 1e-7);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), before);
|
||||
}
|
||||
@@ -1,6 +1,66 @@
|
||||
use super::*;
|
||||
use solvecraft_engine::sketch::CurveKind;
|
||||
|
||||
#[test]
|
||||
fn clicked_periodic_fit_closes_at_start_and_right_click_keeps_natural() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.settle();
|
||||
h.step(&json!({"start":"sketch.spline.fit_point"})).unwrap();
|
||||
for point in [[2, 2, 0], [8, 2, 0], [9, 7, 0], [1, 6, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.curves.is_empty());
|
||||
h.step(&json!({"move":{"world":[2,2,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref(), Some("Close smooth loop"));
|
||||
h.step(&json!({"click":{"world":[2,2,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.curves.len(), 1, "{:?}", h.app.status);
|
||||
assert!(matches!(sk.curves[0].kind, CurveKind::Spline { periodic: true, .. }));
|
||||
sk.validate_periodic_splines().unwrap();
|
||||
assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 1);
|
||||
assert!(h.app.tool.as_ref().unwrap().pts.is_empty());
|
||||
for point in [[15, 2, 0], [20, 2, 0], [20, 6, 0]] {
|
||||
h.step(&json!({"click":{"world":point}})).unwrap();
|
||||
}
|
||||
h.step(&json!({"click":{"world":[20,6,0]},"button":"right"})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.curves.len(), 2);
|
||||
assert!(matches!(sk.curves[1].kind, CurveKind::Spline { periodic: false, .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap();
|
||||
h.settle();
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
h.step(&json!({"start":"sketch.offset"})).unwrap();
|
||||
// Major tip is also a source handle: Offset must choose the actual curve.
|
||||
h.step(&json!({"click":{"world":[10,0,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().picks, vec!["e1"]);
|
||||
h.step(&json!({"click":{"world":[11,0,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.freeform_offsets.len(), 1, "{:?}", h.app.status);
|
||||
assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4);
|
||||
let first = sk.freeform_offsets[0].output.clone();
|
||||
let saved = h.app.session.doc.to_json();
|
||||
h.step(&json!({"click":{"world":[11,0,0]}})).unwrap();
|
||||
assert_eq!(h.app.tool.as_ref().unwrap().picks, vec![first]);
|
||||
h.step(&json!({"click":{"world":[11.5,0,0]}})).unwrap();
|
||||
let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone();
|
||||
assert_eq!(sk.freeform_offsets.len(), 2, "{:?}", h.app.status);
|
||||
assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4);
|
||||
assert!(sk.freeform_offsets.iter().all(|r| r.certificate == 2));
|
||||
assert_eq!(solvecraft_engine::sketch::find_profiles(&sk).len(), 3);
|
||||
let last = sk.curve_index(&sk.freeform_offsets[1].output).unwrap();
|
||||
assert!(sk.exact_segs(last).unwrap()[0].point_at(0.0).dist(Vec2::new(11.5, 0.0)) < 1e-4);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(h.app.session.doc.to_json(), saved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
|
||||
@@ -403,7 +403,7 @@ pub fn hint(id: &str) -> Option<String> {
|
||||
"sketch.slot.center_point" => "Slot: click the centre, an arc centre, then the width",
|
||||
"sketch.slot.arc_three_point" => "Arc slot: click start, a point on the arc, the end, then the width",
|
||||
"sketch.slot.arc_center" => "Arc slot: click the centre, the start, the end, then the width",
|
||||
"sketch.spline.fit_point" => "Spline: click fit points; right-click to finish",
|
||||
"sketch.spline.fit_point" => "Spline: click fit points; click the start to close smoothly, or right-click to finish",
|
||||
"sketch.spline.control_point" | "sketch.spline.control_point_5" => "Control point spline: click control points; right-click to finish",
|
||||
"sketch.text" => "Text: click where the text starts",
|
||||
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
|
||||
@@ -546,6 +546,29 @@ fn round_nice(x: f64) -> f64 {
|
||||
}
|
||||
|
||||
/// A click with an extra tool.
|
||||
pub fn periodic_start_snap(app: &SolveApp, tool: &Tool, proj: &Proj, pos: Pos2) -> Option<Vec2> {
|
||||
if tool.cmd != "sketch.spline.fit_point" || tool.pts.len() < 3 || crate::drag_snap::suppressed() {
|
||||
return None;
|
||||
}
|
||||
let first = tool.pts.first()?.0;
|
||||
let mut distinct = Vec::new();
|
||||
for p in &tool.pts {
|
||||
if !distinct.contains(&p.0) {
|
||||
distinct.push(p.0);
|
||||
}
|
||||
if distinct.len() == 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if distinct.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let state = app.session.world_state();
|
||||
let plane = &state.sketch(app.session.active_sketch?)?.plane;
|
||||
let screen = proj.to_screen(plane.to_world(first))?;
|
||||
(screen.distance(pos) <= 8.0).then_some(first)
|
||||
}
|
||||
|
||||
pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
let Some(m) = mode(tool.cmd) else { return };
|
||||
let hits = pick(app, proj, pos);
|
||||
@@ -658,6 +681,16 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
}
|
||||
}
|
||||
Mode::Points(n) => {
|
||||
if periodic_start_snap(app, tool, proj, pos).is_some() {
|
||||
// Only this deliberate click selects periodic interpolation. A
|
||||
// right-click finish and all previously saved splines stay natural.
|
||||
let params = json!({"points":tool.pts.iter().map(arg).collect::<Vec<_>>(),"periodic":true});
|
||||
if app.run(cmd, params).is_ok() {
|
||||
tool.pts.clear();
|
||||
crate::sketch_dims::reset(tool);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(p) = at else { return };
|
||||
tool.pts.push(p);
|
||||
if n > 0 && tool.pts.len() >= n {
|
||||
@@ -673,21 +706,30 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
return;
|
||||
}
|
||||
if cmd == "sketch.offset" && !tool.picks.is_empty() {
|
||||
if let Some((p, _)) = at {
|
||||
// Dense derived fit nodes must not snap a nearby side click back
|
||||
// onto the source, reducing a deliberate offset to zero.
|
||||
let state = app.session.world_state();
|
||||
let side = active_sketch(app).and_then(|sid| state.sketch(sid)).and_then(|ss| {
|
||||
let (o, d) = proj.ray(pos);
|
||||
ss.plane.intersect_ray(o, d).map(|p| ss.plane.to_local(p))
|
||||
});
|
||||
if let Some(p) = side {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((id, is_point)) = sketch_entity(app, &hits) else { return };
|
||||
if cmd == "sketch.offset" {
|
||||
// First the curve, then the side point.
|
||||
if tool.picks.is_empty() && !is_point {
|
||||
if let Some(sid) = active_sketch(app)
|
||||
&& let Some(id) = hits.iter().find_map(|hit| match hit {
|
||||
Hit::SketchCurve { sketch, id, .. } if *sketch == sid => Some(id.clone()),
|
||||
_ => None,
|
||||
})
|
||||
{
|
||||
tool.picks.push(id);
|
||||
} else if let Some((p, _)) = at {
|
||||
offset_side(app, tool, p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((id, _)) = sketch_entity(app, &hits) else { return };
|
||||
if cmd == "sketch.centerline" {
|
||||
let _ = app.run(cmd, json!({"curves": [id]}));
|
||||
return;
|
||||
@@ -890,8 +932,14 @@ fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool {
|
||||
let st = app.session.world_state();
|
||||
let d = active_sketch(app)
|
||||
.and_then(|s| st.sketch(s))
|
||||
.and_then(|ss| ss.sketch.curve_index(&tool.picks[0]).and_then(|c| ss.sketch.shape(c)))
|
||||
.map(|sh| sh.dist(p))
|
||||
.and_then(|ss| {
|
||||
let curve = ss.sketch.curve_index(&tool.picks[0])?;
|
||||
if let Some(shape) = ss.sketch.shape(curve) {
|
||||
Some(shape.dist(p))
|
||||
} else {
|
||||
ss.sketch.exact_segs(curve)?.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).min_by(f64::total_cmp)
|
||||
}
|
||||
})
|
||||
.unwrap_or(0.0);
|
||||
if d > 1e-6 {
|
||||
let _ = app.run("sketch.offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)}));
|
||||
|
||||
@@ -112,7 +112,9 @@ fn xy(p: &(Vec2, Option<String>)) -> Value {
|
||||
|
||||
pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
crate::sketch3d::on_hover(app, proj, pos);
|
||||
let h = if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
|
||||
let h = if let Some(point) = app.tool.as_ref().and_then(|tool| crate::sketch_tools::periodic_start_snap(app, tool, proj, pos)) {
|
||||
Some((point, Some("Close smooth loop".into())))
|
||||
} else if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
|
||||
crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id)))
|
||||
} else {
|
||||
sketch_point_at(app, proj, pos)
|
||||
|
||||
@@ -225,6 +225,7 @@ fn scene_key(app: &SolveApp) -> u64 {
|
||||
app.session.active_component.hash(&mut h);
|
||||
app.session.active_occurrence_path.hash(&mut h);
|
||||
app.preview.replaced.hash(&mut h);
|
||||
app.tree.redefine.hash(&mut h);
|
||||
crate::theme::is_dark().hash(&mut h);
|
||||
app.session.active_sketch.hash(&mut h);
|
||||
let (minor, _) = grid_step(app.cam.half_height());
|
||||
@@ -374,6 +375,12 @@ fn build_scene(app: &SolveApp) -> GpuScene {
|
||||
}
|
||||
}
|
||||
}
|
||||
let guide_unit = grid_step(app.cam.half_height()).0;
|
||||
for points in crate::sketch_attachment_ui::guide_lines(app) {
|
||||
for (a, b) in dashes(&points, &[guide_unit * 0.25, guide_unit * 0.15]) {
|
||||
sc.line(a.to_f32(), b.to_f32(), c4(tk.warning), 1.5, true);
|
||||
}
|
||||
}
|
||||
sc
|
||||
}
|
||||
|
||||
@@ -594,6 +601,9 @@ pub fn model_bounds(app: &SolveApp) -> solvecraft_engine::geom::Aabb3 {
|
||||
}
|
||||
}
|
||||
}
|
||||
for point in crate::sketch_attachment_ui::guide_lines(app).into_iter().flatten() {
|
||||
b.add(point);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -1795,6 +1805,12 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
crate::drag_snap::show(app, &painter, &proj);
|
||||
hover_highlight(app, &painter, &proj);
|
||||
points_2d(app, &painter, &proj);
|
||||
if let Some((plane, sketch)) = crate::sketch_attachment_ui::lost_guide(app)
|
||||
&& let Some((low, high)) = sketch.bounds()
|
||||
&& let Some(at) = proj.to_screen(plane.to_world(solvecraft_engine::geom::Vec2::new((low.x + high.x) * 0.5, high.y)))
|
||||
{
|
||||
painter.text(at - vec2(0.0, 12.0), Align2::CENTER_BOTTOM, "Saved sketch plane — attachment lost", FontId::proportional(12.0), t.warning);
|
||||
}
|
||||
if let Some(bx) = app.viewport.boxsel {
|
||||
let col = if bx.crossing { t.box_crossing } else { t.box_window };
|
||||
painter.rect_filled(bx.rect, 0.0, col.gamma_multiply(0.12));
|
||||
|
||||
Reference in new issue
Block a user