feat: preserve body history and placed modelling workflows

Record ownership transfers in the timeline so earlier Join/Cut membership,
world placement, machining data and styles survive component moves, edits,
independent copying, undo and reopening. Preserve pending positions in history
snapshots and keep constructor transfers ordered during feature redefinition.

Retain shallow sheet pockets and exact planar intersection islands through
unfold/refold, with depth-aware flat previews and DXF export. Store explicit
3D wire coordinate spaces and migrate known legacy provenance without moving
saved points or analytic fits.

Infer unique eligible cylindrical Emboss targets, align Revolve manipulators
with actual extent geometry, and discard obsolete preview errors after input
correction. Certify constructed and immutable imported face styles using exact
source metadata while preserving legacy face names. Upgrade designs to format10
with bounded validation and geometry-preserving legacy migration.
This commit is contained in:
bherbruck committed 2026-10-11 03:59:12 -04:00
1 parent 0398a813c8
commit c1d819f235
86 files changed
+6011 -488

No files matched your search

+1 -1
View File
@@ -1120,7 +1120,7 @@ fn entries(app: &SolveApp, comp: u64, folder: &str) -> Vec<Entry> {
.state()
.bodies
.iter()
.filter(|b| doc.body_component(&b.name, b.feature) == comp)
.filter(|b| doc.model_body_component(b) == comp)
.map(|b| Entry {
key: b.name.clone(),
row_key: format!("b:{}", b.name),
+282 -42
View File
@@ -234,6 +234,12 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
let focus = std::mem::take(&mut d.focus);
let half_height = app.cam.half_height();
let axis = revolve_axis(app, d);
if matches!(d.kind, Kind::Revolve { .. }) {
if let Some(axis) = axis {
draw_revolve(app, ui, painter, proj, d, base, bs, axis, focus);
}
return;
}
// Two sides: the second distance gets its own arrow the other way.
if let (Kind::Extrude { direction: crate::dialogs::TWO_SIDES, distance2, .. }, Some(n)) = (&mut d.kind, dir)
&& let Some(l2) = app.session.doc.eval(distance2, ValueKind::Length).ok().filter(|v| v.is_finite())
@@ -285,11 +291,6 @@ fn draw(app: &SolveApp, ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj,
});
let before = value.clone();
let mut box_at = bs + vec2(16.0, -30.0);
if let (Some(axis), Some(angle)) = (axis, len.filter(|_| kind == ValueKind::Angle))
&& let Some(at) = rotator(ui, painter, proj, base, axis, angle, value)
{
box_at = at + vec2(14.0, -12.0);
}
if let (Some(n), Some(l)) = (dir, len.filter(|_| kind == ValueKind::Length)) {
let n = n * sign;
let baseline = to_span.map(f64::abs).unwrap_or(0.0);
@@ -464,56 +465,295 @@ pub(crate) fn revolve_axis(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Vec3)> {
Some((vector("origin")?, vector("dir")?.normalized()?))
}
/// The revolve angle rotator: an arc from the profile around the axis with a handle at its end
/// that drags the angle (5° steps; Alt or Ctrl: free). Returns the handle's screen position.
/// Draw angular extents in the same clockwise sense as the modeled solid.
#[allow(clippy::too_many_arguments)]
fn rotator(ui: &mut egui::Ui, painter: &egui::Painter, proj: &Proj, p0: Vec3, (o, a): (Vec3, Vec3), angle: f64, value: &mut String) -> Option<Pos2> {
let t = Tokens::get();
let c = o + a * (p0 - o).dot(a);
let r = (p0 - c).len();
if r < 1e-9 {
return None;
fn draw_revolve(
app: &SolveApp,
ui: &mut egui::Ui,
painter: &egui::Painter,
proj: &Proj,
d: &mut Dialog,
base: Vec3,
bs: Pos2,
axis: (Vec3, Vec3),
focus: bool,
) {
let to_mode = d.inputs.iter().any(|input| input.label == "To Object");
let extent = revolve_display_extent(app, d, axis).unwrap_or(solvecraft_engine::doc::RevolveExtent {
start: 0.0,
sweep: f64::NAN,
rotation_axis: axis.1,
display_start: 0.0,
curved_to: false,
});
let mut box_at = bs + vec2(16.0, -30.0);
if !extent.curved_to && extent.sweep.is_finite() {
let (origin, direction) = axis;
let centre = origin + direction * (base - origin).dot(direction);
let radius = (base - centre).len();
if radius > 1e-9 {
let e1 = (base - centre) / radius;
let e2 = extent.rotation_axis.cross(e1);
let handed = -direction.dot(extent.rotation_axis);
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)));
if let Some(end) = proj.to_screen(at(extent.display_start + extent.sweep)) {
box_at = end + vec2(14.0, -12.0);
}
if !to_mode {
let symmetric = !d.extra.contains_key("angle2") && d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric");
let other_angle = d
.extra
.get("angle2")
.and_then(|value| value.as_str())
.and_then(|value| app.session.doc.eval(value, ValueKind::Angle).ok())
.unwrap_or(0.0);
let range = if extent.sweep < 0.0 { (-std::f64::consts::TAU, 0.0) } else { (0.0, std::f64::consts::TAU) };
if let Kind::Revolve { angle, .. } = &mut d.kind {
let boundary = extent.display_start + extent.sweep;
if let Some(end) = rotation_handle(
ui,
painter,
proj,
centre,
e1,
e2,
direction,
radius,
boundary,
if symmetric { 1.0 / (1.0 - handed * 0.5) } else { 1.0 },
if symmetric { 0.0 } else { -other_angle * (1.0 - handed) },
(range.0 - other_angle, range.1 - other_angle),
"revolve",
angle,
) {
box_at = end + vec2(14.0, -12.0);
}
if symmetric {
rotation_handle(
ui,
painter,
proj,
centre,
e1,
e2,
direction,
radius,
extent.display_start,
-2.0 * handed,
0.0,
range,
"revolve2",
angle,
);
}
}
if let Some(other) = d.extra.get_mut("angle2") {
let mut value = other.as_str().map(str::to_owned).unwrap_or_else(|| other.to_string());
let before = value.clone();
let first_angle = extent.sweep - other_angle;
rotation_handle(
ui,
painter,
proj,
centre,
e1,
e2,
direction,
radius,
extent.display_start,
-handed,
0.0,
(range.0 - first_angle, range.1 - first_angle),
"revolve2",
&mut value,
);
if value != before {
*other = serde_json::Value::String(value);
}
}
}
}
}
let e1 = (p0 - c) / r;
let e2 = a.cross(e1);
let at = |th: f64| c + (e1 * th.cos() + e2 * th.sin()) * r;
let n = 48;
let pts: Vec<Pos2> = (0..=n).filter_map(|i| proj.to_screen(at(angle * i as f64 / n as f64))).collect();
let end = proj.to_screen(at(angle))?;
let handle = egui::Rect::from_center_size(end, vec2(20.0, 20.0));
let resp = ui.interact(handle, egui::Id::new("sc_rotator"), egui::Sense::drag());
let hot = resp.hovered() || resp.dragged();
let col = if hot { t.accent } else { t.manipulator };
painter.add(egui::Shape::line(pts, Stroke::new(2.0, col)));
if let (Some(cs), Some(ps)) = (proj.to_screen(c), proj.to_screen(p0)) {
painter.add(egui::Shape::dashed_line(&[cs, ps], Stroke::new(1.0, col), 4.0, 3.0));
}
painter.circle(end, if hot { 7.0 } else { 5.5 }, col, Stroke::new(1.5, Color32::WHITE));
egui::Area::new(egui::Id::new("sc_canvas_area")).fixed_pos(box_at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
if to_mode {
let text = if extent.curved_to {
"To surface".into()
} else if extent.sweep.is_finite() {
format!("Sweep {:.1}°", extent.sweep.to_degrees())
} else {
"Choose target".into()
};
ui.label(text).on_hover_text("Determined by To Object. Choose Angle to edit the angular extent.");
} else if let Kind::Revolve { angle, .. } = &mut d.kind {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Angle").color(Tokens::get().text_dim).size(11.5));
let id = egui::Id::new("sc_canvas_value");
let response = ui.add(egui::TextEdit::singleline(angle).id(id).desired_width(72.0));
crate::params_dialog::complete(ui, &response, angle);
if focus {
response.request_focus();
let n = angle.chars().count();
let mut state = egui::text_edit::TextEditState::load(ui.ctx(), id).unwrap_or_default();
state
.cursor
.set_char_range(Some(egui::text_selection::CCursorRange::two(egui::text::CCursor::new(0), egui::text::CCursor::new(n))));
state.store(ui.ctx(), id);
}
});
}
});
});
}
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();
let ss = state.sketch(*sketch)?;
let regions: Vec<_> = d
.inputs
.first()?
.items
.iter()
.filter_map(|item| match item {
Sel::Profile { sketch: id, index } if id == sketch => ss.profiles.get(*index).map(|profile| profile.region.clone()),
_ => None,
})
.collect();
let regions = solvecraft_engine::sketch::merge_regions(&regions);
let plane = profile_plane(app, *sketch)?;
let to_mode = d.inputs.iter().any(|input| input.label == "To Object");
let to = if to_mode {
let point = crate::dialogs::object_point(&d.inputs, "To Object")?;
Some(Vec3::new(point.get(0)?.as_f64()?, point.get(1)?.as_f64()?, point.get(2)?.as_f64()?))
} else {
None
};
let Kind::Revolve { angle, .. } = &d.kind else { return None };
let angles = if to_mode {
(0.0, None)
} else {
let angle = app.session.doc.eval(angle, ValueKind::Angle).ok()?;
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(
&state,
&plane,
&regions,
axis,
angles,
d.extra.get("direction").and_then(serde_json::Value::as_str) == Some("symmetric"),
to,
)
.ok()
}
#[allow(clippy::too_many_arguments)]
fn rotation_handle(
ui: &mut egui::Ui,
painter: &egui::Painter,
proj: &Proj,
centre: Vec3,
e1: Vec3,
e2: Vec3,
axis: Vec3,
radius: f64,
boundary: f64,
scale: f64,
offset: f64,
range: (f64, f64),
name: &str,
value: &mut String,
) -> Option<Pos2> {
let end = proj.to_screen(centre + (e1 * boundary.cos() + e2 * boundary.sin()) * radius)?;
crate::scenario::publish_handle(name, end);
let response = ui.interact(egui::Rect::from_center_size(end, vec2(20.0, 20.0)), egui::Id::new(("sc_rotator", name)), egui::Sense::drag());
let hot = response.hovered() || response.dragged();
let color = if hot { Tokens::get().accent } else { Tokens::get().manipulator };
painter.circle(end, if hot { 7.0 } else { 5.5 }, color, Stroke::new(1.5, Color32::WHITE));
if hot {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
}
if resp.dragged()
&& let Some(p) = resp.interact_pointer_pos()
if response.dragged()
&& let Some(pointer) = response.interact_pointer_pos()
{
let (ro, rd) = proj.ray(p);
let den = rd.dot(a);
if den.abs() > 1e-9 {
let q = ro + rd * ((c - ro).dot(a) / den) - c;
let mut th = q.dot(e2).atan2(q.dot(e1)).to_degrees().rem_euclid(360.0);
let free = ui.input(|i| i.modifiers.alt || i.modifiers.ctrl || i.modifiers.command);
if !free {
th = (th / 5.0).round() * 5.0;
let (origin, direction) = proj.ray(pointer);
let den = direction.dot(axis);
let (a, b) = ((range.0 - offset) / scale, (range.1 - offset) / scale);
let bounds = (a.min(b), a.max(b));
// Near edge-on, a plane intersection magnifies subpixel pointer error.
let raw = if den.abs() > 0.05 {
let point = origin + direction * ((centre - origin).dot(axis) / den) - centre;
Some(point.dot(e2).atan2(point.dot(e1)))
} else {
projected_rotation_angle(proj, pointer, centre, e1, e2, radius, boundary, bounds)
};
if let Some(raw) = raw {
let boundary_angle = (-4..=4)
.map(|k| raw + k as f64 * std::f64::consts::TAU)
.filter(|angle| *angle >= bounds.0 - 1e-4 && *angle <= bounds.1 + 1e-4)
.min_by(|a, b| (a - boundary).abs().total_cmp(&(b - boundary).abs()))
.unwrap_or_else(|| raw.clamp(bounds.0, bounds.1));
let mut angle = (boundary_angle.clamp(bounds.0, bounds.1) * scale + offset).to_degrees();
if !ui.input(|input| input.modifiers.alt || input.modifiers.ctrl || input.modifiers.command) {
angle = (angle / 5.0).round() * 5.0;
}
if th < 0.5 {
th = 360.0;
if angle.abs() < 0.05 {
angle = 0.0;
}
let s = format!("{th:.1}");
*value = format!("{} deg", s.trim_end_matches('0').trim_end_matches('.'));
let text = format!("{angle:.1}");
*value = format!("{} deg", text.trim_end_matches('0').trim_end_matches('.'));
}
}
Some(end)
}
/// An edge-on ring has two equally valid depths; retain the branch nearest the current handle.
#[allow(clippy::too_many_arguments)]
fn projected_rotation_angle(
proj: &Proj,
pointer: Pos2,
centre: Vec3,
e1: Vec3,
e2: Vec3,
radius: f64,
current: f64,
(lo, hi): (f64, f64),
) -> Option<f64> {
let distance = |angle: f64| {
proj.to_screen(centre + (e1 * angle.cos() + e2 * angle.sin()) * radius)
.map(|point| f64::from(point.distance_sq(pointer)))
.unwrap_or(f64::INFINITY)
};
let step = (hi - lo) / 720.0;
let mut best = None;
for i in 0..=720 {
let angle = lo + step * i as f64;
let score = distance(angle);
if !score.is_finite() {
continue;
}
if best.is_none_or(|(old_angle, old_score): (f64, f64)| {
score < old_score - 1e-4 || (score - old_score).abs() <= 1e-4 && (angle - current).abs() < (old_angle - current).abs()
}) {
best = Some((angle, score));
}
}
let (angle, _) = best?;
let (mut a, mut b) = ((angle - step).max(lo), (angle + step).min(hi));
for _ in 0..24 {
let (left, right) = (a + (b - a) / 3.0, b - (b - a) / 3.0);
if distance(left) <= distance(right) {
b = right;
} else {
a = left;
}
}
Some((a + b) / 2.0)
}
fn arrow(painter: &egui::Painter, a: Pos2, b: Pos2, col: Color32) {
let v = b - a;
let l = v.length();
+1 -8
View File
@@ -248,14 +248,7 @@ pub fn target_bodies(app: &SolveApp, target: &Target) -> Vec<String> {
/// Bodies in a component and its subcomponents.
pub fn component_bodies(app: &SolveApp, id: u64) -> Vec<String> {
let doc = &app.session.doc;
app.session
.model
.state()
.bodies
.iter()
.filter(|b| doc.component_within(doc.body_component(&b.name, b.feature), id))
.map(|b| b.name.clone())
.collect()
app.session.model.state().bodies.iter().filter(|b| doc.component_within(doc.model_body_component(b), id)).map(|b| b.name.clone()).collect()
}
/// The eight radial commands, clockwise from the top.
+23 -6
View File
@@ -1054,9 +1054,10 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
ui.horizontal(|ui| {
let active = i == d.active;
// A chip: "↖ Select" (outlined) while empty, "N selected" (filled) once picked.
let filled = !inp.items.is_empty();
let custom_axis = i == 1 && inp.items.is_empty() && matches!(d.kind, Kind::Revolve { axis: Some(_), .. });
let filled = !inp.items.is_empty() || custom_axis;
let text = if filled {
RichText::new(format!("↖ {} selected", inp.items.len())).color(Color32::WHITE)
RichText::new(if custom_axis { "↖ Custom Axis".into() } else { format!("↖ {} selected", inp.items.len()) }).color(Color32::WHITE)
} else {
RichText::new("↖ Select").color(t.text)
};
@@ -1069,7 +1070,20 @@ fn input_rows(d: &mut Dialog, ui: &mut egui::Ui) {
.sense(egui::Sense::CLICK);
let chip = ui.add(b);
publish_row(&chip);
if chip.on_hover_text(format!("{} (click to pick into this input)", hint(inp))).clicked() {
let tooltip = if custom_axis {
if let Kind::Revolve { axis: Some(axis), .. } = &d.kind {
format!(
"Custom axis\nOrigin: {}\nDirection: {}\nClick to select a replacement axis",
axis.get("origin").map(ToString::to_string).unwrap_or_default(),
axis.get("dir").map(ToString::to_string).unwrap_or_default()
)
} else {
hint(inp).to_string()
}
} else {
format!("{} (click to pick into this input)", hint(inp))
};
if chip.on_hover_text(tooltip).clicked() {
activate = Some(i);
}
if !inp.items.is_empty() && ui.small_button("×").on_hover_text("Clear the selection").clicked() {
@@ -1600,7 +1614,10 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
ui.end_row();
ui.label("");
ui.add(
egui::Label::new("Pick a cylinder wall to wrap the profiles around it. Leave Target Face empty for planar embossing.").wrap(),
egui::Label::new(
"Orthogonal line profiles tangent to one supported cylinder wrap automatically. Pick a wall to choose a target.",
)
.wrap(),
);
ui.end_row();
}
@@ -2259,7 +2276,7 @@ pub(crate) fn emboss_profile_plane(app: &SolveApp, sketch: u64) -> Result<Option
let body = model.body(&face.body).ok_or("the Emboss target body is missing")?;
let owner = app.session.doc.feature(sketch.feature).map(|feature| feature.component).unwrap_or(0);
let plane = sketch.plane;
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body.feature).map_err(|error| error.to_string())?;
let frame = face.sketch_frame(&app.session.doc, feature.component, owner, body).map_err(|error| error.to_string())?;
solvecraft_engine::geom::Plane::new(
solvecraft_engine::doc::apply_point(&frame, plane.origin),
solvecraft_engine::doc::apply_vector(&frame, plane.x),
@@ -2953,7 +2970,7 @@ fn emboss_face_sel(s: &Session, component: u64, face: &solvecraft_engine::doc::E
let source = s.model.state();
let body = source.body(&face.body)?;
let index = solvecraft_engine::doc::naming::face_names(body).iter().position(|name| name == persistent)?;
let owner = s.doc.body_component(&face.body, body.feature);
let owner = s.doc.model_body_component(body);
let world_name = s.doc.world_body_name(&source, &face.body, owner, &face.path);
let author = solvecraft_engine::doc::EmbossFace::component_frame(&s.doc, component, &face.author_path).ok()?;
let point = solvecraft_engine::doc::apply_point(&author, face.at);
+1 -1
View File
@@ -273,7 +273,7 @@ fn pt(v: Vec3) -> Value {
fn occurrence_of_body(s: &Session, body: &str) -> Option<u64> {
let st = s.world_state();
let b = st.body(body)?;
let comp = s.doc.body_component(&b.name, b.feature);
let comp = s.doc.model_body_component(b);
if comp == 0 { Some(0) } else { s.doc.occurrence_of(comp).map(|o| o.id) }
}
+50 -16
View File
@@ -807,28 +807,35 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
continue;
}
ui.menu_button(format!("Faces on {}", body.name), |ui| {
let names = solvecraft_engine::doc::naming::face_names(body);
if let Ok(faces) = body.body.faces((body.body.size() * 2e-3).max(1e-3)) {
let mut seen = std::collections::BTreeSet::new();
let mesh = body.mesh();
let mut points = std::collections::BTreeMap::new();
for (triangle, index) in mesh.triangles.iter().zip(&mesh.tri_face) {
if let std::collections::btree_map::Entry::Vacant(entry) = points.entry(*index as usize)
&& let Some([a, b, c]) = mesh.tri(triangle)
{
entry.insert((a + b + c) * (1. / 3.));
}
}
for face in faces {
let Some(name) = names.get(face.index) else { continue };
let name = solvecraft_engine::doc::naming::strip_piece(name);
if !seen.insert(name.to_string()) {
continue;
}
let Some((id, role)) = name.strip_prefix('F').and_then(|name| name.split_once(':')) else { continue };
let Ok(origin_feature) = id.parse() else { continue };
let target = solvecraft_engine::doc::config::ConfigFaceTarget {
body: body.name.clone(),
origin_feature,
name: name.into(),
point: face.centroid,
// A curved face centroid can lie on another surface.
// Pick an interior triangle point on this actual face.
let Some(point) = points.get(&face.index).copied() else { continue };
let Ok(target) = app.session.doc.configuration_face_target(body, face.index, point) else { continue };
let supported = if target.certificate.is_some() {
// Capture read this exact face's current source certificate.
// Enumerate its pieces once in the command, not per menu face.
app.session.doc.certify_config_face(&target).is_ok()
} else {
app.session.doc.configuration_face_indices(body, &target).is_ok()
};
if app.session.doc.certify_config_face(&target).is_err() {
if !supported || !seen.insert(target.name.clone()) {
continue;
}
let Some((_, role)) = target.name.split_once(':') else { continue };
if ui.button(role).clicked() {
pick = Some(json!({"face_appearance":{"body":body.name,"point":face.centroid}}));
pick = Some(json!({"face_appearance":{"target":target}}));
ui.close();
}
}
@@ -926,7 +933,7 @@ pub fn commands(app: &SolveApp, k: &Mo, inputs: &[SelInput]) -> Result<Vec<(Stri
if let solvecraft_engine::Sel::Body { name } = x
&& let Some(b) = app.session.world_state().body(name)
{
let comp = app.session.doc.body_component(&b.name, b.feature);
let comp = app.session.doc.model_body_component(b);
if let Some(o) = app.session.doc.occurrence_of(comp).map(|o| o.id)
&& !occs.contains(&o)
{
@@ -1127,6 +1134,33 @@ mod tests {
}
}
#[test]
fn configuration_annulus_picker_exposes_both_certificates_despite_shared_legacy_name() {
let mut h = crate::scenario::Harness::new();
h.call("ui.resize", json!({"width":1400,"height":900}));
h.run(&json!([
{"call":"engine.execute","params":{"command":"sketch.create","params":{"plane":"XY"}}},
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[0,0],"radius":10,"id":"outer"}}},
{"call":"engine.execute","params":{"command":"sketch.circle.center","params":{"center":[0,0],"radius":9.999,"id":"inner"}}},
{"call":"engine.execute","params":{"command":"sketch.finish","params":{}}},
{"call":"engine.execute","params":{"command":"solid.extrude","params":{"distance":10,"profiles":[{"point":[9.9995,0]}],"body_name":"Ring"}}},
{"start":"config.table"},{"widget":"Add Column"},{"widget":"Face Appearance"},{"widget":"Faces on Ring"}
]))
.unwrap();
assert!(h.widget("side:outer").is_some());
assert!(h.widget("side:inner").is_some());
h.run(&json!([{"widget":"side:outer"},{"widget":"Library"},{"widget":"ABS - Red"},{"widget":"Apply"}])).unwrap();
let solvecraft_engine::doc::config::Column::FaceAppearance(target) = &h.app.session.doc.configs.columns[0] else { panic!() };
assert!(matches!(&target.certificate, Some(solvecraft_engine::doc::config::FaceCertificate::ExtrudeSide { curve, .. }) if curve == "outer"));
let state = h.app.session.model.state();
let body = state.body("Ring").unwrap();
let indices = h.app.session.doc.configuration_face_indices(body, target).unwrap();
assert_eq!(indices.len(), 2);
let painted = h.app.session.doc.face_colors(body);
assert_eq!(painted.len(), 2);
assert!(painted.iter().all(|(index, _)| indices.contains(index)));
}
#[test]
fn configuration_style_picker_applies_then_inherits_material() {
let steps: Value = serde_json::from_str(include_str!("../tests/scenarios/s80_configuration_styles.json")).unwrap();
+55 -3
View File
@@ -714,9 +714,17 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
if !(lo.0.is_finite() && hi.0 > lo.0 && hi.1 > lo.1) {
return true;
}
// Fit with a margin, leaving the dialog's side free.
let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), rect.max - vec2(320.0, 60.0));
let area = if area.width() < 100.0 { rect.shrink(40.0) } else { area };
// Fit all retained islands to the exposed viewport. The command panel can grow
// to its width limit, so its actual edge, rather than a fixed reserve, bounds the view.
let panel_left = painter
.ctx()
.memory(|m| m.area_rect(egui::Id::new(("sc_dialog", "FLAT PATTERN"))))
.map(|panel| panel.left())
.unwrap_or(rect.right() - crate::frame::DIALOG_MAX_W);
let area = Rect::from_min_max(rect.min + vec2(40.0, 60.0), pos2(panel_left.min(rect.right()) - 20.0, rect.bottom() - 60.0));
if area.width() <= 0.0 || area.height() <= 0.0 {
return true;
}
let scale = (f64::from(area.width()) / (hi.0 - lo.0)).min(f64::from(area.height()) / (hi.1 - lo.1));
let ox = f64::from(area.center().x) - (lo.0 + hi.0) * 0.5 * scale;
let oy = f64::from(area.center().y) + (lo.1 + hi.1) * 0.5 * scale;
@@ -733,6 +741,26 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
painter.add(egui::Shape::closed_line(pts, Stroke::new(1.4, t.text)));
}
}
// A pocket retains material below (or above) the cut. Dashed contours and a depth
// annotation distinguish it from the solid through-cut boundaries.
let pockets: Vec<&Value> = v["pockets"].as_array().into_iter().flatten().collect();
for pocket in &pockets {
let outer: Vec<(f64, f64)> = pocket["outline"].as_array().into_iter().flatten().filter_map(read).collect();
for contour in std::iter::once(&pocket["outline"]).chain(pocket["cutouts"].as_array().into_iter().flatten()) {
let mut pts: Vec<Pos2> = contour.as_array().into_iter().flatten().filter_map(read).map(to).collect();
if pts.len() >= 3 {
pts.push(pts[0]);
painter.extend(egui::Shape::dashed_line(&pts, Stroke::new(1.6, t.accent), 3.0, 3.0));
}
}
if !outer.is_empty() {
let bounds = outer.iter().map(|p| to(*p)).fold(Rect::NOTHING, |r, p| r.union(Rect::from_min_max(p, p)));
let anchor = bounds.center_top() - vec2(0.0, 6.0);
if let Some(label) = pocket_label(pocket) {
painter.text(anchor, Align2::CENTER_BOTTOM, label, FontId::proportional(12.0), t.accent);
}
}
}
for b in v["bends"].as_array().into_iter().flatten() {
let (Some(a), Some(e)) = (read(&b["start"]), read(&b["end"])) else { continue };
let col = if b["direction"] == "up" { up_col } else { down_col };
@@ -746,9 +774,33 @@ pub fn overlay(painter: &egui::Painter, rect: Rect, d: &Dialog) -> bool {
painter.line_segment([pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)], Stroke::new(2.0, col));
painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, label, FontId::proportional(12.0), t.text_dim);
}
if !pockets.is_empty() {
y += 20.0;
painter.extend(egui::Shape::dashed_line(
&[pos2(rect.left() + 14.0, y + 7.0), pos2(rect.left() + 38.0, y + 7.0)],
Stroke::new(1.6, t.accent),
3.0,
3.0,
));
painter.text(pos2(rect.left() + 44.0, y), Align2::LEFT_TOP, "Pocket boundary (material remains)", FontId::proportional(12.0), t.text_dim);
}
true
}
fn pocket_label(pocket: &Value) -> Option<String> {
let depth = pocket["depth_mm"].as_f64()?;
if !depth.is_finite() || depth <= 0.0 {
return None;
}
let side = match pocket["side"].as_str()? {
"top" => "Top",
"bottom" => "Bottom",
"internal" => "Internal",
_ => return None,
};
Some(format!("{side} pocket: {depth} mm deep"))
}
#[cfg(test)]
#[path = "dialogs_sheet_tests.rs"]
mod tests;
+124
View File
@@ -127,6 +127,130 @@ fn flat_exports_use_the_save_picker() {
assert_eq!(picks[1].1, vec!["dxf"]);
}
#[test]
fn shallow_pocket_overlay_labels_depth_without_drawing_a_through_void() {
let mut app = plate();
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sketch.create", json!({"plane": "XY", "name": "Pocket"})).unwrap();
app.run("sketch.rectangle.two_point", json!({"p0": [20, 20], "p1": [30, 30]})).unwrap();
app.run("sketch.finish", json!({})).unwrap();
app.run("solid.extrude", json!({"sketch": "Pocket", "distance": 1, "operation": "cut"})).unwrap();
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
assert_eq!(flat["cutouts"], json!([]));
assert_eq!(flat["pockets"][0]["depth_mm"], 1.0);
app.start("sheet.flat_pattern");
let mut dialog = app.dialog.clone().unwrap();
let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") };
*data = Some(Ok(flat));
let ctx = egui::Context::default();
let mut output = ctx.run_ui(egui::RawInput::default(), |ui| {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Middle, egui::Id::new("flat-pocket-test")));
assert!(overlay(&painter, Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0)), &dialog));
});
output.textures_delta.clear();
let texts: Vec<&str> = output
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Text(text) => Some(text.galley.text()),
_ => None,
})
.collect();
assert!(texts.contains(&"Bottom pocket: 1 mm deep"));
assert!(texts.contains(&"Pocket boundary (material remains)"));
let paths: Vec<_> = output
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Path(path) => Some(path),
_ => None,
})
.collect();
assert!(!paths.is_empty(), "the retained sheet outline is drawn");
assert!(paths.iter().all(|p| p.fill == Color32::TRANSPARENT), "pockets must not be rendered as filled voids");
let lines = output.shapes.iter().filter(|s| matches!(s.shape, egui::Shape::LineSegment { .. })).count();
assert!(lines > 6, "pocket contours and legend use dashed lines, not through-cut strokes");
}
#[test]
fn flat_pattern_islands_fit_before_the_actual_command_panel() {
let mut app = plate();
app.run("sheet.flange", json!({"sketch": "Base"})).unwrap();
app.run("sketch.create", json!({"plane": "XY", "name": "Retain"})).unwrap();
for x in [20, 60] {
app.run("sketch.rectangle.two_point", json!({"p0": [x, 20], "p1": [x + 10, 30]})).unwrap();
}
app.run("sketch.finish", json!({})).unwrap();
app.run("solid.extrude", json!({"sketch": "Retain", "profiles": "all", "through_all": true, "direction": "symmetric", "operation": "intersect"}))
.unwrap();
let flat = app.session.execute("sheet.flat_pattern", &json!({})).unwrap();
assert_eq!(flat["outline"].as_array().unwrap().len(), 2);
app.start("sheet.flat_pattern");
let mut dialog = app.dialog.clone().unwrap();
let Kind::Sheet(Sm::FlatPattern { data, .. }) = &mut dialog.kind else { panic!("flat dialog") };
*data = Some(Ok(flat));
let ctx = egui::Context::default();
let id = egui::Id::new(("sc_dialog", "FLAT PATTERN"));
let mut output = egui::FullOutput::default();
for _ in 0..3 {
output = ctx.run_ui(egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), ..Default::default() }, |ui| {
crate::frame::docked("FLAT PATTERN", id, pos2(1400.0, 294.0), 540.0, crate::frame::Width::Normal).min_width(360.0).show(ui.ctx(), |ui| {
ui.label("Flat size: 50 × 10 × 2.5 mm");
});
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Background, egui::Id::new("island-fit")));
assert!(overlay(&painter, Rect::from_min_max(pos2(250.0, 126.0), pos2(1400.0, 832.0)), &dialog));
});
output.textures_delta.clear();
}
let panel = ctx.memory(|m| m.area_rect(id)).unwrap();
let outlines: Vec<_> = output
.shapes
.iter()
.filter_map(|s| match &s.shape {
egui::Shape::Path(p) if p.closed && p.fill == Color32::TRANSPARENT => Some(p),
_ => None,
})
.collect();
assert_eq!(outlines.len(), 2, "both retained islands are drawn");
assert!(
outlines.iter().flat_map(|p| &p.points).all(|p| p.x <= panel.left() - 19.0),
"both complete islands must remain exposed before the command panel: {panel:?}"
);
}
#[test]
fn pocket_export_button_uses_the_picker_and_preserves_depth_layers() {
let mut h = crate::scenario::Harness::new();
for (command, params) in [
("sketch.create", json!({"plane":"XY","name":"Base"})),
("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[100,60]})),
("sketch.finish", json!({})),
("sheet.flange", json!({"sketch":"Base"})),
("sketch.create", json!({"plane":"XY","name":"Pocket"})),
("sketch.rectangle.two_point", json!({"p0":[20,20],"p1":[30,30]})),
("sketch.finish", json!({})),
("solid.extrude", json!({"sketch":"Pocket","distance":1,"operation":"cut"})),
] {
h.app.run(command, params).unwrap();
}
let path = h.dir.join("picked-pocket.dxf");
let chosen = path.to_string_lossy().into_owned();
h.app.services.pick_save = Some(Box::new(move |name, extensions| {
assert!(name.ends_with("-Body1.dxf"));
assert_eq!(extensions, &["dxf"]);
Some(chosen.clone())
}));
h.app.start("sheet.flat_pattern");
h.settle();
let undo = h.app.session.undo.len();
h.step(&json!({"widget":"Export DXF…"})).unwrap();
let exported = std::fs::read_to_string(&path).expect("actual Export DXF button writes the picker destination");
assert!(exported.contains("POCKET_BOTTOM_Z0.000000_TO_1.000000_MM"));
assert!(exported.contains("OUTLINE"));
assert_eq!(h.app.session.undo.len(), undo, "export does not alter the part");
assert!(h.app.dialog.is_some(), "export keeps the flat viewer open");
}
#[test]
#[cfg(not(target_arch = "wasm32"))]
fn bend_table_picker_writes_the_current_body() {
+4
View File
@@ -67,10 +67,14 @@ pub mod recovery_ui;
pub mod ref_images;
#[cfg(test)]
mod revolve_edit_tests;
#[cfg(test)]
mod revolve_manipulator_tests;
pub mod scenario;
pub mod selection;
pub mod shortcut_box;
pub mod sketch3d;
#[cfg(test)]
mod sketch3d_frame_ui_tests;
pub mod sketch_dims;
#[cfg(test)]
mod sketch_edit_tests;
+24 -1
View File
@@ -309,10 +309,33 @@ fn delete_key_handles_every_kind_and_asks_about_dependents() {
fn menu_delete_items_use_the_same_path() {
let mut app = sample_app();
let b = body(&app);
app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
let original_volume = solvecraft_engine::kernel::measure(&app.session.world_state().body(&b).unwrap().body).unwrap().volume;
let created = app.run("component.from_bodies", json!({"bodies": [b]})).unwrap();
let c = app.session.doc.components[0].id;
let transfer = created["components"][0]["feature"].as_u64().unwrap();
let transfer_name = app.session.doc.feature(transfer).unwrap().name.clone();
let saved = app.session.doc.to_json();
act(&mut app, &Target::Component { id: c }, "Delete");
let confirmation = app.menu.confirm.as_ref().expect("recorded transfer history needs confirmation");
assert!(confirmation.report["deleted"].as_array().unwrap().iter().any(|name| name.as_str() == Some(transfer_name.as_str())));
assert!(crate::delete::confirm(&mut app, false));
assert_eq!(app.session.doc.to_json(), saved, "cancel keeps component and transfer history");
act(&mut app, &Target::Component { id: c }, "Delete");
assert!(crate::delete::confirm(&mut app, true));
assert!(app.session.doc.components.is_empty());
assert!(app.session.doc.feature(transfer).is_none());
let restored = app.session.world_state();
assert_eq!(restored.bodies.len(), 1, "the original joined plate stays intact");
let plate = restored.body(&b).unwrap();
assert_eq!(plate.component, 0);
assert!((solvecraft_engine::kernel::measure(&plate.body).unwrap().volume - original_volume).abs() < 1e-6);
assert!(plate.mesh().contains(solvecraft_engine::geom::Vec3::new(40.0, 35.0, 12.0)));
let mut reopened = solvecraft_engine::Session::new(solvecraft_engine::doc::Document::from_json(&app.session.doc.to_json()).unwrap());
reopened.refresh();
let reopened_state = reopened.world_state();
assert_eq!(reopened_state.bodies.len(), 1);
assert_eq!(reopened_state.body(&b).unwrap().component, 0);
assert!((solvecraft_engine::kernel::measure(&reopened_state.body(&b).unwrap().body).unwrap().volume - original_volume).abs() < 1e-6);
// A sketch from the browser: asks, since its features go too.
let id = app.session.doc.features.iter().find(|f| f.name == "Base").unwrap().id;
act(&mut app, &Target::Sketch { id }, "Delete");
+22
View File
@@ -3,6 +3,28 @@ use crate::scenario::Harness;
use serde_json::json;
use solvecraft_engine::Sel;
#[test]
fn replaced_body_name_uses_evaluated_owner_in_browser_and_component_actions() {
let mut h = Harness::new();
h.app.run("solid.box", json!({"length":10,"width":10,"height":10,"body_name":"Block"})).unwrap();
let transferred = h.app.run("component.from_bodies", json!({"bodies":["Block"]})).unwrap();
let owner = transferred["components"][0]["component"].as_u64().unwrap();
assert_eq!(context_menu::component_bodies(&h.app, owner), ["Block"]);
h.app.run("solid.remove", json!({"bodies":["Block"]})).unwrap();
h.app.run("solid.box", json!({"corner":[30,0,0],"length":5,"width":5,"height":5,"body_name":"Block"})).unwrap();
h.frames(2);
let state = h.app.session.model.state();
let body = state.body("Block").unwrap();
assert_eq!(h.app.session.doc.model_body_component(body), 0);
assert_eq!(context_menu::component_bodies(&h.app, 0), ["Block"]);
assert!(context_menu::component_bodies(&h.app, owner).is_empty());
assert_eq!(super::entries(&h.app, 0, "bodies").iter().map(|entry| entry.key.as_str()).collect::<Vec<_>>(), ["Block"]);
assert!(super::entries(&h.app, owner, "bodies").is_empty());
let bounds = h.app.session.world_state().body("Block").unwrap().mesh().bounds();
assert!((bounds.min.x - 30.0).abs() < 1e-9);
assert!((bounds.max.x - 35.0).abs() < 1e-9);
}
fn fixture() -> (Harness, u64, u64, u64) {
let mut h = Harness::new();
let part = h.app.run("component.create", json!({"name":"Block"})).unwrap();
+52
View File
@@ -8,6 +8,58 @@ use solvecraft_engine::{Sel, Session};
use crate::dialogs::{Dialog, Kind, apply_commands};
use crate::{Services, SolveApp};
#[test]
fn cylinder_emboss_empty_target_face_applies_automatic_wrap_and_deboss() {
for deboss in [false, true] {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.cylinder", json!({"radius":10,"height":20,"body_name":"Sleeve"})).unwrap();
let initial = h.app.session.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume;
let sketch =
h.app.run("sketch.create", json!({"plane":{"origin":[0,10,0],"x_dir":[-1,0,0],"y_dir":[0,0,1]},"name":"Badge"})).unwrap()["sketch"]
.as_u64()
.unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[-2,5],"p1":[2,10]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
h.app.session.selection = vec![Sel::Profile { sketch, index: 0 }];
h.app.start("solid.emboss");
h.frames(2);
if deboss {
h.settle();
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Type:Deboss"}})).unwrap();
}
let dialog = h.app.dialog.as_ref().unwrap();
assert!(dialog.inputs[1].items.is_empty());
let commands = apply_commands(&h.app, dialog).unwrap();
assert!(commands[0].1.get("face").is_none());
let preview = h.app.session.preview_world(&commands).unwrap();
let expected = if deboss { -19.0 } else { 21.0 };
let preview_delta = preview.after.body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - initial;
assert!(
(preview_delta - expected).abs() < 0.05,
"deboss={deboss}, preview delta={preview_delta}, expected={expected}, commands={commands:?}"
);
h.step(&json!({"key":"Enter"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog);
let feature = h.app.session.doc.features.last().unwrap().clone();
let solvecraft_engine::doc::FeatureKind::Emboss { face: Some(face), deboss: actual, .. } = &feature.kind else {
panic!("automatic Apply must save its cylinder anchor: {:?}", feature.kind)
};
assert_eq!(*actual, deboss);
assert_eq!(face.body, "Sleeve");
assert!(
(h.app.session.model.state().body("Sleeve").unwrap().body.tessellate_with(1e-4, true).unwrap().measure().volume - initial - expected)
.abs()
< 0.05
);
h.app.edit_feature(feature.id);
h.frames(2);
assert!(matches!(h.app.dialog.as_ref().unwrap().inputs[1].items.first(), Some(Sel::Face { body, .. }) if body == "Sleeve"));
h.step(&json!({"key":"Escape"})).unwrap();
assert_eq!(h.app.session.doc.feature(feature.id).unwrap().kind, feature.kind);
}
}
#[test]
fn cylinder_emboss_target_face_and_edit_keep_the_anchored_operation() {
let mut h = crate::scenario::Harness::new();
+58 -14
View File
@@ -103,6 +103,28 @@ impl PreviewState {
Err(e) => self.error = Some(e),
}
}
/// Errors validate only the current inputs. Keep the last good geometry while
/// computing new inputs, but never let an obsolete result block their Apply.
fn poll(&mut self, key: u64) {
if self.shown_key != Some(key) {
self.error = None;
}
if let Some((k, rx)) = &self.running {
let k = *k;
let job = match rx.try_recv() {
Ok(job) => Some(job),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => Some(Err("the preview failed".into())),
};
if let Some(job) = job {
self.running = None;
if k == key {
self.apply(k, job);
}
}
}
}
}
/// Keep the preview in step with the open dialog: start a computation when its inputs changed,
@@ -127,20 +149,7 @@ pub fn update(app: &mut SolveApp, ctx: &egui::Context) {
format!("{colors:?}").hash(&mut h);
let key = h.finish();
let pv = &mut app.preview;
if let Some((k, rx)) = &pv.running {
let k = *k;
match rx.try_recv() {
Ok(job) => {
pv.running = None;
pv.apply(k, job);
}
Err(TryRecvError::Empty) => {}
Err(TryRecvError::Disconnected) => {
pv.running = None;
pv.apply(k, Err("the preview failed".into()));
}
}
}
pv.poll(key);
if pv.running.is_none() && pv.shown_key != Some(key) {
pv.started = crate::now_ms();
let scratch = app.session.scratch();
@@ -367,6 +376,41 @@ mod tests {
Colors { body: [1, 1, 1, 255], added: [2, 2, 2, 255], removed: [3, 3, 3, 100], edge: [0, 0, 0, 255] }
}
#[test]
fn correcting_inputs_clears_old_errors_and_discards_late_old_jobs() {
let mut pv = PreviewState {
shown_key: Some(1),
error: Some("unknown_angle".into()),
active: true,
replaced: vec!["Turn".into()],
key: 9,
..PreviewState::default()
};
let (tx, rx) = std::sync::mpsc::channel();
pv.running = Some((1, rx));
pv.poll(2);
assert!(pv.error.is_none(), "the corrected inputs can Apply while their preview computes");
assert!(pv.running.is_some());
assert!(pv.active);
assert_eq!(pv.replaced, ["Turn"]);
assert_eq!(pv.key, 9, "the last good geometry stays visible");
tx.send(Err("unknown_angle".into())).unwrap();
pv.poll(2);
assert!(pv.error.is_none(), "a late obsolete result cannot disable Apply again");
assert!(pv.running.is_none());
assert_eq!(pv.shown_key, Some(1));
assert_eq!(pv.key, 9);
let (tx, rx) = std::sync::mpsc::channel();
pv.running = Some((2, rx));
tx.send(Err("current invalid expression".into())).unwrap();
pv.poll(2);
assert_eq!(pv.error.as_deref(), Some("current invalid expression"));
assert_eq!(pv.shown_key, Some(2));
pv.poll(2);
assert_eq!(pv.error.as_deref(), Some("current invalid expression"), "current errors still prevent invalid Apply");
}
fn count(bytes: &[u8], stride: usize, col: [u8; 4]) -> usize {
bytes.chunks(stride).filter(|v| v.get(24..28) == Some(&col[..])).count()
}
+6
View File
@@ -109,6 +109,12 @@ fn second_active_occurrence_noop_preserves_definition_axis_target_and_instances(
let (origin, _) = crate::canvas::revolve_axis(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
assert!((origin - expected_origin).len() < 1e-8);
assert!(origin.len() > 50.0, "fixture must distinguish first and second placements");
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 placed = preview.after.bodies.iter().find(|body| body.mesh().bounds().center().x > 50.0).unwrap();
assert!(placed.mesh().triangles.len() > 10);
assert_eq!(placed.mesh().bounds(), before.iter().find(|(_, bounds)| bounds.center().x > 50.0).unwrap().1);
assert!(h.widget("↖ Custom Axis").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);
let after: Vec<_> = h.app.session.world_state().bodies.iter().map(|b| (b.name.clone(), b.mesh().bounds())).collect();
@@ -0,0 +1,272 @@
use crate::scenario::Harness;
use serde_json::{Value, json};
use solvecraft_engine::doc::{FeatureKind, kernel};
fn fixture(mut h: Harness, extra: Value) -> Harness {
h.app.run("sketch.create", json!({"plane":"XY","name":"Profile"})).unwrap();
h.app.run("sketch.rectangle.two_point", json!({"p0":[10,0],"p1":[15,5]})).unwrap();
h.app.run("sketch.finish", json!({})).unwrap();
let mut params = json!({"sketch":"Profile","axis":"Y","angle":"90 deg","operation":"new","name":"Turn","body_name":"Main"});
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
h.app.run("solid.revolve", params).unwrap();
h.frames(4);
h
}
fn open(h: &mut Harness) {
h.step(&json!({"call":"ui.view","params":{"view":"front"}})).unwrap();
h.step(&json!({"call":"ui.view","params":{"view":"fit"}})).unwrap();
h.step(&json!({"call":"ui.scroll","params":{"x":700,"y":450,"delta":-400}})).unwrap();
h.step(&json!({"call":"ui.editFeature","params":{"feature":"Turn"}})).unwrap();
h.frames(8);
}
fn at_angle(degrees: f64) -> Value {
let a = degrees.to_radians();
json!([12.5 * a.cos(), 2.5, 12.5 * a.sin()])
}
fn handle_at(h: &mut Harness, name: &str, world: Value) {
let actual = h.at(&json!({"handle":name})).unwrap();
let expected = h.at(&json!({"world":world})).unwrap();
assert!((actual[0] - expected[0]).hypot(actual[1] - expected[1]) < 1.0, "{actual:?} != {expected:?}");
}
fn volume(h: &Harness) -> f64 {
kernel::measure(&h.app.session.world_state().body("Main").unwrap().body).unwrap().volume
}
fn angle(h: &Harness) -> String {
let FeatureKind::Revolve { angle, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
angle.clone()
}
fn expected_volume(degrees: f64) -> f64 {
0.5 * (225.0 - 100.0) * 5.0 * degrees.to_radians()
}
#[test]
fn actual_positive_negative_axis_and_negative_sweep_drags_follow_geometry() {
for (params, sign) in [(json!({}), 1.0), (json!({"axis":{"origin":[0,0,0],"dir":[0,-2,0]}}), 1.0), (json!({"angle":"-90 deg"}), -1.0)] {
let mut h = fixture(Harness::new(), params.clone());
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 undo = h.app.session.undo.len();
open(&mut h);
handle_at(&mut h, "revolve", at_angle(sign * 90.0));
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(sign * 60.0)}]})).unwrap();
let commands = crate::dialogs::apply_commands(&h.app, h.app.dialog.as_ref().unwrap()).unwrap();
let preview = h.app.session.preview_world(&commands).unwrap();
assert!((kernel::measure(&preview.after.body("Main").unwrap().body).unwrap().volume - expected_volume(60.0)).abs() < 0.03);
let bounds = preview.after.body("Main").unwrap().mesh().bounds();
if sign > 0.0 {
assert!(bounds.min.z > -1e-8 && bounds.max.z > 12.9);
} else {
assert!(bounds.max.z < 1e-6 && bounds.min.z < -12.9, "{params}: {bounds:?}");
}
h.step(&json!({"widget":"OK"})).unwrap();
assert_eq!(angle(&h), if params.get("angle").is_some() { "-60 deg" } else { "60 deg" });
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
assert_eq!(h.app.session.undo.len(), undo + 1);
h.app.run("edit.undo", json!({})).unwrap();
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
}
}
#[test]
fn symmetric_boundary_drags_edit_included_angle_and_cancel_is_lossless() {
for (handle, sign) in [("revolve", 1.0), ("revolve2", -1.0)] {
let mut h = fixture(Harness::new(), json!({"angle":"120 deg","direction":"symmetric"}));
let original = serde_json::to_value(&*h.app.session.doc).unwrap();
let undo = h.app.session.undo.len();
open(&mut h);
handle_at(&mut h, "revolve", at_angle(60.0));
handle_at(&mut h, "revolve2", at_angle(-60.0));
h.step(&json!({"drag":[{"handle":handle},{"world":at_angle(sign * 45.0)}]})).unwrap();
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
assert_eq!(entered, "90 deg");
h.step(&json!({"widget":"Cancel"})).unwrap();
assert_eq!(serde_json::to_value(&*h.app.session.doc).unwrap(), original);
assert_eq!(h.app.session.undo.len(), undo);
open(&mut h);
h.step(&json!({"drag":[{"handle":handle},{"world":at_angle(sign * 45.0)}]})).unwrap();
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
assert_eq!(entered, "90 deg", "second {handle} drag");
h.step(&json!({"widget":"OK"})).unwrap();
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1, "{handle}: {} angle {}", volume(&h), angle(&h));
let bounds = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
assert!((bounds.max.z - 15.0 / 2.0f64.sqrt()).abs() < 0.03 && (bounds.min.z + 15.0 / 2.0f64.sqrt()).abs() < 0.03);
let path = h.dir.join("symmetric.solvecraft");
h.app.run("file.save", json!({"path":path})).unwrap();
h.app.run("doc.open", json!({"path":path})).unwrap();
assert_eq!(angle(&h), "90 deg");
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1);
}
}
#[test]
fn to_object_displays_derived_sweep_without_dormant_edit_until_angle_selected() {
let mut h = fixture(Harness::new(), json!({"angle":"45 deg","angle2":"30 deg","to":[0,2,12]}));
let original = serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap();
open(&mut h);
assert!(h.widget("Sweep 90.0°").is_some());
assert!(crate::scenario::handle_at("revolve").is_none());
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"180 deg"})).unwrap();
let crate::dialogs::Kind::Revolve { angle, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
assert_eq!(angle, "45 deg");
h.step(&json!({"widget":"OK"})).unwrap();
assert_eq!(serde_json::to_value(&h.app.session.doc.find_feature("Turn").unwrap().kind).unwrap(), original);
open(&mut h);
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Type:Angle"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"90 deg"})).unwrap();
h.step(&json!({"widget":"OK"})).unwrap();
let FeatureKind::Revolve { to, angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
assert!(to.is_none());
assert_eq!(angle2.as_deref(), Some("30 deg"));
assert!((volume(&h) - expected_volume(120.0)).abs() < 0.03);
}
#[test]
fn two_side_boundary_preserves_other_angle_and_parameter_binding() {
let mut h = fixture(Harness::new(), json!({"angle":"60 deg","angle2":"30 deg"}));
h.app.run("solid.box", json!({"length":1,"width":1,"height":"(d2 / (1 rad)) * 1 mm","corner":[30,0,0],"body_name":"Dependent"})).unwrap();
let names = h.app.session.doc.find_feature("Turn").unwrap().param_names.clone();
open(&mut h);
handle_at(&mut h, "revolve", at_angle(60.0));
handle_at(&mut h, "revolve2", at_angle(-30.0));
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at_angle(-45.0)}]})).unwrap();
h.step(&json!({"widget":"OK"})).unwrap();
let FeatureKind::Revolve { angle, angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
assert_eq!(angle, "60 deg");
assert_eq!(angle2.as_deref(), Some("45 deg"));
assert_eq!(h.app.session.doc.find_feature("Turn").unwrap().param_names, names);
assert!((h.app.session.world_state().body("Dependent").unwrap().mesh().bounds().max.z - std::f64::consts::FRAC_PI_4).abs() < 1e-8);
assert!((volume(&h) - expected_volume(105.0)).abs() < 0.03);
}
#[test]
fn selected_second_placement_custom_axis_handle_and_preview_stay_in_its_frame() {
let mut h = Harness::new();
let owner = h.app.run("component.create", json!({"name":"Owner"})).unwrap();
let mut h = fixture(h, json!({"axis":{"origin":[0,0,0],"dir":[0,2,0]}}));
let second = h.app.run("occurrence.copy", json!({"component":owner["component"],"translate":[100,0,20]})).unwrap()["occurrence"].clone();
h.app.run("occurrence.move", json!({"occurrence":second,"axis":[0,0,1],"angle":"17 deg"})).unwrap();
h.app.run("component.activate", json!({"component":owner["component"],"occurrence":second,"path":[second]})).unwrap();
let frame = h.app.session.active_component_frame();
let world = |degrees: f64| {
let a = degrees.to_radians();
let p = solvecraft_engine::doc::apply_point(&frame, solvecraft_engine::geom::Vec3::new(12.5 * a.cos(), 2.5, 12.5 * a.sin()));
json!([p.x, p.y, p.z])
};
let axis = match &h.app.session.doc.find_feature("Turn").unwrap().kind {
FeatureKind::Revolve { axis, .. } => axis.clone(),
_ => panic!("revolve"),
};
open(&mut h);
assert!(h.widget("↖ Custom Axis").is_some());
handle_at(&mut h, "revolve", world(90.0));
h.step(&json!({"drag":[{"handle":"revolve"},{"world":world(60.0)}]})).unwrap();
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 placed = preview.after.bodies.iter().find(|body| body.mesh().bounds().center().x > 50.0).unwrap();
assert!((kernel::measure(&placed.body).unwrap().volume - expected_volume(60.0)).abs() < 0.03);
h.step(&json!({"widget":"OK"})).unwrap();
let FeatureKind::Revolve { axis: after, angle, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
assert_eq!(after, &axis);
assert_eq!(angle, "60 deg");
assert_eq!(h.app.session.active_occurrence_path.as_deref(), Some(&[second.as_u64().unwrap()][..]));
}
#[test]
fn curved_to_object_has_surface_feedback_without_an_angular_handle_or_input() {
let mut h = Harness::new();
h.app.run("solid.cylinder", json!({"radius":3,"height":5,"base":[0,0,12],"axis":"Y","operation":"new","body_name":"Target"})).unwrap();
let mut h = fixture(h, json!({}));
open(&mut h);
h.step(&json!({"click":{"handle":"dialog:Type"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Type:To Object"}})).unwrap();
let mut dialog = h.app.dialog.take().unwrap();
dialog.active = dialog.inputs.iter().position(|input| input.label == "To Object").unwrap();
dialog.pick(&h.app.session, solvecraft_engine::Sel::Vertex { body: "Target".into(), point: solvecraft_engine::geom::Vec3::new(0.0, 2.0, 15.0) });
h.app.dialog = Some(dialog);
h.frames(8);
assert!(h.widget("To surface").is_some());
assert!(crate::scenario::handle_at("revolve").is_none());
assert!(crate::scenario::handle_at("revolve2").is_none());
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"180 deg"})).unwrap();
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
assert_eq!(entered, "90 deg");
h.step(&json!({"widget":"Cancel"})).unwrap();
assert_eq!(angle(&h), "90 deg");
}
#[test]
fn free_drag_and_invalid_expression_stay_editable_before_commit() {
let mut h = fixture(Harness::new(), json!({}));
open(&mut h);
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(37.3)}],"ctrl":true})).unwrap();
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
assert!((h.app.session.doc.eval(entered, solvecraft_engine::doc::expr::Kind::Angle).unwrap().to_degrees() - 37.3).abs() < 0.2);
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"unknown_angle"})).unwrap();
h.frames(4);
assert!(h.widget("unknown_angle").is_some());
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_some());
h.step(&json!({"click":{"handle":"dialog:Angle"}})).unwrap();
h.step(&json!({"key":"A","cmd":true})).unwrap();
h.step(&json!({"text":"60 deg"})).unwrap();
let crate::dialogs::Kind::Revolve { angle: entered, .. } = &h.app.dialog.as_ref().unwrap().kind else { panic!("revolve") };
assert_eq!(entered, "60 deg");
h.step(&json!({"widget":"OK"})).unwrap();
assert!(h.app.dialog.is_none(), "{:?}", h.app.dialog.as_ref().map(|dialog| &dialog.error));
assert_eq!(angle(&h), "60 deg");
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
}
#[test]
fn second_side_can_be_dragged_to_zero_without_becoming_full_turn() {
let mut h = fixture(Harness::new(), json!({"angle":"60 deg","angle2":"30 deg"}));
open(&mut h);
h.step(&json!({"drag":[{"handle":"revolve2"},{"world":at_angle(0.0)}]})).unwrap();
assert_eq!(h.app.dialog.as_ref().unwrap().extra.get("angle2"), Some(&json!("0 deg")));
h.step(&json!({"widget":"OK"})).unwrap();
let FeatureKind::Revolve { angle2, .. } = &h.app.session.doc.find_feature("Turn").unwrap().kind else { panic!("revolve") };
assert_eq!(angle2.as_deref(), Some("0 deg"));
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
}
#[test]
fn edge_on_ring_drag_uses_nearest_projected_branch() {
let mut h = fixture(Harness::new(), json!({}));
open(&mut h);
h.step(&json!({"call":"ui.view","params":{"view":"top"}})).unwrap();
handle_at(&mut h, "revolve", at_angle(90.0));
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(60.0)}]})).unwrap();
h.step(&json!({"widget":"OK"})).unwrap();
assert_eq!(angle(&h), "60 deg");
assert!((volume(&h) - expected_volume(60.0)).abs() < 0.03);
}
#[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 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);
open(&mut h);
handle_at(&mut h, "revolve", at_angle(180.0));
handle_at(&mut h, "revolve2", at_angle(60.0));
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);
open(&mut h);
h.step(&json!({"drag":[{"handle":"revolve"},{"world":at_angle(135.0)}]})).unwrap();
h.step(&json!({"widget":"OK"})).unwrap();
assert_eq!(angle(&h), "90 deg");
let after = h.app.session.world_state().body("Main").unwrap().mesh().bounds();
assert!((after.min.x + 15.0 / 2.0f64.sqrt()).abs() < 0.03 && (after.max.x - 15.0 / 2.0f64.sqrt()).abs() < 0.03);
assert!(after.min.z > 7.0 && after.max.z > 14.9);
assert!((volume(&h) - expected_volume(90.0)).abs() < 0.1);
}
@@ -0,0 +1,67 @@
//! Real 3D drawing and triad input in a rotated second component occurrence.
use serde_json::json;
use solvecraft_engine::geom::Vec3;
#[test]
fn second_occurrence_drawn_wire_fit_picks_and_world_triad_drag_stay_aligned() {
let mut h = crate::scenario::Harness::new();
let component = h.app.run("component.create", json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
let first = h.app.session.doc.occurrence_of(component).unwrap().id;
let second = h.app.run("occurrence.copy", json!({"component":component})).unwrap()["occurrence"].as_u64().unwrap();
h.app.run("occurrence.move", json!({"occurrence":second,"translate":[20,30,40],"axis":[0,0,1],"angle":"90 deg","capture":true})).unwrap();
h.app.run("component.activate", json!({"component":component,"occurrence":second})).unwrap();
h.app.run("solid.box", json!({"corner":[17,32,45],"length":2,"width":2,"height":10,"body_name":"SnapVertices"})).unwrap();
h.app.run("browser.visibility", json!({"paths":[[first]],"visible":false})).unwrap();
let sketch = h.app.run("sketch.create", json!({"plane":"XY","name":"Wire"})).unwrap()["sketch"].as_u64().unwrap();
h.app.ui.show_origin = false;
h.settle();
h.step(&json!({"widget":"3D Sketch"})).unwrap();
assert!(
crate::sketch3d::active(&h.app),
"palette click must enable 3D: raw={:?}, world={:?}, status={:?}, active={:?}",
h.app.session.model.state().sketch(sketch).unwrap().sketch.view,
h.app.session.world_state().sketch(sketch).unwrap().sketch.view,
h.app.status,
h.app.session.active_sketch
);
h.call("ui.view", json!({"view":"home"}));
h.call("ui.view", json!({"view":"fit"}));
h.app.start("sketch.line");
h.frames(2);
for world in [Vec3::new(17.0, 32.0, 45.0), Vec3::new(17.0, 32.0, 55.0)] {
let [x, y] = h.at(&json!({"world":[world.x,world.y,world.z]})).unwrap();
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
let picked = crate::sketch3d::point_at(&h.app, &proj, egui::pos2(x as f32, y as f32));
assert!(picked.is_some_and(|(p, _)| p.dist(world) < 1e-5), "world={world:?}, picked={picked:?}, status={:?}", h.app.status);
}
h.step(&json!({"click":{"world":[17,32,45]}})).unwrap();
h.step(&json!({"click":{"world":[17,32,55]}})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
h.step(&json!({"key":"Escape"})).unwrap();
assert!(h.app.tool.is_none());
let raw = h.app.session.model.state();
let wire = raw.sketch(sketch).unwrap().sketch.wires.first().expect("actual off-plane clicks create a wire");
assert!(wire.fit[0].dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-6, "{:?}", wire.fit);
assert!(wire.fit[1].dist(Vec3::new(2.0, 3.0, 15.0)) < 1e-6, "{:?}", wire.fit);
let world = h.app.session.world_state();
let shown = &world.sketch(sketch).unwrap().sketch.wires[0];
assert!(shown.fit[0].dist(Vec3::new(17.0, 32.0, 45.0)) < 1e-6, "{:?}", shown.fit);
assert!(shown.fit[1].dist(Vec3::new(17.0, 32.0, 55.0)) < 1e-6, "{:?}", shown.fit);
h.step(&json!({"click":{"world":[17,32,45]}})).unwrap();
let [x0, y0] = h.at(&json!({"handle":"triad_x"})).unwrap();
let before = h.at(&json!({"world":[17,32,45]})).unwrap();
let after = h.at(&json!({"world":[21,32,45]})).unwrap();
let history = h.app.session.undo.len();
h.call("ui.drag", json!({"x0":x0,"y0":y0,"x1":x0+after[0]-before[0],"y1":y0+after[1]-before[1],"steps":8}));
let moved = h.app.session.model.state();
let fit = &moved.sketch(sketch).unwrap().sketch.wires[0].fit;
assert!(fit[0].dist(Vec3::new(2.0, -1.0, 5.0)) < 1e-5, "{fit:?}");
assert!(fit[1].dist(Vec3::new(2.0, 3.0, 15.0)) < 1e-6);
let placed = h.app.session.world_state();
assert!(placed.sketch(sketch).unwrap().sketch.wires[0].fit[0].dist(Vec3::new(21.0, 32.0, 45.0)) < 1e-5);
assert_eq!(h.app.session.undo.len(), history + 1);
h.app.run("edit.undo", json!({})).unwrap();
let restored = h.app.session.model.state();
assert!(restored.sketch(sketch).unwrap().sketch.wires[0].fit[0].dist(Vec3::new(2.0, 3.0, 5.0)) < 1e-6);
}