use super::*;
use super::sketch_edit_ops::is_plain_number_literal;
fn seed_sketch_value() -> serde_json::Value {
serde_json::json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0 },
{ "id": 1, "x": 20.0, "y": 0.0 },
{ "id": 2, "x": 20.0, "y": 12.0 },
{ "id": 3, "x": 0.0, "y": 12.0 },
{ "id": 4, "x": 34.0, "y": 6.0 },
{ "id": 5, "x": 40.0, "y": 6.0 }
],
"geometries": [
{ "id": 10, "type": "line", "points": [0, 1], "construction": false },
{ "id": 11, "type": "line", "points": [1, 2], "construction": false },
{ "id": 12, "type": "line", "points": [2, 3], "construction": false },
{ "id": 13, "type": "line", "points": [3, 0], "construction": false },
{ "id": 20, "type": "circle", "points": [4, 5], "construction": false }
],
"constraints": [
{ "id": 0, "type": "⏚", "points": [0] },
{ "id": 1, "type": "━", "points": [0, 1] },
{ "id": 2, "type": "⟺", "points": [0, 1], "value": 20.0 },
{ "id": 3, "type": "│", "points": [1, 2] },
{ "id": 4, "type": "⟺", "points": [1, 2], "value": 12.0 },
{ "id": 5, "type": "━", "points": [2, 3] },
{ "id": 6, "type": "│", "points": [3, 0] }
]
})
}
fn seed_sketch_history() -> String {
serde_json::json!({
"features": [{
"type": "S",
"inputParams": { "id": "Sk" },
"persistentData": {
"basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
"sketch": seed_sketch_value()
}
}]
})
.to_string()
}
#[test]
fn enter_reads_the_seed_and_commit_writes_it_back() {
let mut engine = EngineState::new();
engine.set_history_json(&seed_sketch_history()).unwrap();
assert!(!engine.sketch_mode());
let diag_json = engine.enter_sketch_mode("Sk").expect("enter");
assert!(engine.sketch_mode());
assert_eq!(engine.sketch_edit_feature_id(), Some("Sk"));
let session = engine.sketch_edit_session().expect("session");
assert_eq!(session.diagnostics.dof, 4);
let diag: serde_json::Value = serde_json::from_str(&diag_json).unwrap();
assert_eq!(diag["dof"], 4);
engine.exit_sketch_mode(true);
assert!(!engine.sketch_mode());
let index = engine.history.index_of("Sk").unwrap();
let pd = engine.history.feature_persistent_data(index).unwrap();
assert!(pd.get("sketch").is_some(), "commit dropped persistentData.sketch");
assert_eq!(pd["sketch"]["points"].as_array().unwrap().len(), 6);
}
#[test]
fn enter_rejects_missing_and_non_sketch_features() {
let mut engine = EngineState::new();
engine
.set_history_json(
&serde_json::json!({
"features": [{ "type": "P.CU", "inputParams": { "id": "Box" } }]
})
.to_string(),
)
.unwrap();
assert!(engine.enter_sketch_mode("Nope").is_err());
assert!(engine.enter_sketch_mode("Box").is_err());
assert!(!engine.sketch_mode());
}
#[test]
fn new_sketch_cancel_deletes_the_feature() {
let mut engine = EngineState::new();
engine.set_history_json(&seed_sketch_history()).unwrap();
let before = engine.history_len();
engine.new_sketch("XZ").expect("new sketch");
assert!(engine.sketch_mode());
assert_eq!(engine.history_len(), before + 1);
engine.exit_sketch_mode(false);
assert!(!engine.sketch_mode());
assert_eq!(engine.history_len(), before);
}
#[test]
fn new_sketch_commit_keeps_the_feature_with_the_analytic_basis() {
let mut engine = EngineState::new();
let before = engine.history_len();
engine.new_sketch("YZ").expect("new sketch");
let id = engine.sketch_edit_feature_id().unwrap().to_string();
engine.exit_sketch_mode(true);
assert!(!engine.sketch_mode());
assert_eq!(engine.history_len(), before + 1);
let index = engine.history.index_of(&id).expect("kept feature");
let pd = engine.history.feature_persistent_data(index).unwrap();
assert_eq!(pd["basis"]["z"], serde_json::json!([1.0, 0.0, 0.0]));
}
#[test]
fn new_sketch_rejects_an_unknown_plane() {
let mut engine = EngineState::new();
assert!(engine.new_sketch("ZZ").is_err());
assert!(!engine.sketch_mode());
}
fn seed_sketch_engine() -> EngineState {
let mut engine = EngineState::new();
engine.set_history_json(&seed_sketch_history()).unwrap();
engine.enter_sketch_mode("Sk").expect("enter");
engine.resize(800.0, 600.0);
engine.camera.eye = [10.0, 6.0, 50.0];
engine.camera.target = [10.0, 6.0, 0.0];
engine.camera.up = [0.0, 1.0, 0.0];
engine.camera.projection = crate::view::Projection::Orthographic { half_height: 30.0 };
engine
}
fn pixel_of(engine: &EngineState, u: f64, v: f64) -> (f64, f64) {
let (sx, sy, _) = engine.camera.project([u, v, 0.0]);
(sx, sy)
}
#[test]
fn sketch_uv_at_round_trips_known_plane_points() {
let engine = seed_sketch_engine();
for (u, v) in [(14.0, 9.0), (0.0, 0.0), (34.0, 6.0)] {
let (px, py) = pixel_of(&engine, u, v);
let (ru, rv) = engine.sketch_uv_at(px, py).expect("ray hits the plane");
assert!(
(ru - u).abs() < 1e-3 && (rv - v).abs() < 1e-3,
"uv ({u}, {v}) -> ({ru}, {rv})"
);
}
let mut bare = EngineState::new();
bare.resize(800.0, 600.0);
assert!(bare.sketch_uv_at(400.0, 300.0).is_none());
}
#[test]
fn sketch_drag_moves_a_free_point_and_skips_a_locked_one() {
let mut engine = seed_sketch_engine();
let (gx, gy) = pixel_of(&engine, 34.0, 6.0);
assert!(engine.sketch_drag_begin(gx, gy), "grabbed the free center");
let (tx, ty) = pixel_of(&engine, 36.0, 8.0);
engine.sketch_drag_to(tx, ty);
engine.sketch_drag_end();
let session = engine.sketch_edit_session().expect("session");
let p4 = session.doc.point(&serde_json::json!(4)).expect("p4");
assert!((p4.x - 36.0).abs() < 1e-3 && (p4.y - 8.0).abs() < 1e-3, "p4 = {p4:?}");
assert_eq!(session.diagnostics.dof, 4);
let (bx, by) = pixel_of(&engine, 0.0, 0.0);
assert!(!engine.sketch_drag_begin(bx, by), "locked corner not grabbable");
let p0 = engine
.sketch_edit_session()
.unwrap()
.doc
.point(&serde_json::json!(0))
.unwrap();
assert!(p0.x.abs() < 1e-9 && p0.y.abs() < 1e-9, "p0 moved: {p0:?}");
}
#[test]
fn entering_a_sketch_clears_the_model_selection() {
let mut engine = EngineState::new();
engine.set_history_json(&seed_sketch_history()).unwrap();
engine.resize(800.0, 600.0);
engine.emphasis.selected_faces.insert("Plane_Face".to_string());
engine.emphasis.selected_edges.insert("Some|Edge[0]".to_string());
assert!(!engine.emphasis.selected_faces.is_empty());
engine.enter_sketch_mode("Sk").expect("enter sketch");
assert!(
engine.emphasis.selected_faces.is_empty(),
"faces cleared on sketch entry"
);
assert!(
engine.emphasis.selected_edges.is_empty(),
"edges cleared on sketch entry"
);
}
#[test]
fn entity_list_rows_and_ref_select_hover() {
let mut engine = seed_sketch_engine();
assert_eq!(engine.sketch_point_rows().len(), 6, "seed has 6 points");
assert_eq!(engine.sketch_geometry_rows().len(), 5, "4 lines + 1 circle");
assert!(
!engine.sketch_constraint_rows().is_empty(),
"seed has constraints"
);
engine.sketch_select_entity("geometry", serde_json::json!(20), false);
let circle = engine
.sketch_geometry_rows()
.into_iter()
.find(|r| r.label.starts_with("circle:20"))
.expect("a circle:20 row");
assert!(circle.selected, "selected geometry row reflects the selection");
engine.sketch_select_entity("point", serde_json::json!(4), true);
assert_eq!(engine.sketch_selection_count(), 2);
engine.sketch_hover_entity("point", serde_json::json!(1));
assert!(engine.take_sketch_list_hover(), "hover arms the guard");
assert!(!engine.take_sketch_list_hover(), "guard is one-shot");
}
#[test]
fn dragging_a_point_onto_another_infers_a_coincident_constraint() {
let mut engine = seed_sketch_engine();
let before = engine.sketch_constraint_count();
let (gx, gy) = pixel_of(&engine, 34.0, 6.0);
assert!(engine.sketch_drag_begin(gx, gy), "grabbed the free center");
let (tx, ty) = pixel_of(&engine, 20.0, 0.0);
engine.sketch_drag_to(tx, ty);
engine.sketch_drag_end();
assert_eq!(
engine.sketch_constraint_count(),
before + 1,
"a coincident constraint should be inferred on drop"
);
let session = engine.sketch_edit_session().expect("session");
let equal = session
.doc
.constraints
.iter()
.find(|c| c.ctype() == Some("≡"))
.expect("an ≡ constraint");
let ids: Vec<String> = equal
.points()
.iter()
.map(crate::sketch::doc::id_key)
.collect();
assert!(ids.contains(&"4".to_string()), "≡ references dragged id 4: {ids:?}");
}
#[test]
fn locked_sketch_left_drag_never_moves_the_camera() {
let mut engine = seed_sketch_engine();
assert!(
engine.sketch_camera_locked(),
"sketch entry locks the camera by default"
);
let (target0, eye0) = (engine.camera.target, engine.camera.eye);
assert!(
!engine.pointer_down(400.0, 300.0, crate::controls::BUTTON_LEFT),
"locked-sketch left press must not start a camera gesture"
);
engine.pointer_move(470.0, 350.0);
engine.pointer_up();
assert_eq!(engine.camera.target, target0, "left drag panned the target");
assert_eq!(engine.camera.eye, eye0, "left drag moved the eye");
assert!(
engine.pointer_down(400.0, 300.0, crate::controls::BUTTON_RIGHT),
"right press must capture a pan gesture"
);
engine.pointer_move(470.0, 350.0);
engine.pointer_up();
assert_ne!(
engine.camera.target, target0,
"right drag should pan the target while locked"
);
engine.toggle_sketch_camera_lock();
assert!(!engine.sketch_camera_locked());
assert!(
engine.pointer_down(400.0, 300.0, crate::controls::BUTTON_LEFT),
"unlocked-sketch left press captures the orbit gesture"
);
engine.pointer_up();
}
#[test]
fn sketch_click_selects_toggles_and_clears() {
let mut engine = seed_sketch_engine();
assert_eq!(engine.sketch_selection_count(), 0);
let (x4, y4) = pixel_of(&engine, 34.0, 6.0);
engine.sketch_click_at(x4, y4, false);
assert_eq!(engine.sketch_selection_count(), 1);
let (x1, y1) = pixel_of(&engine, 20.0, 0.0);
engine.sketch_click_at(x1, y1, true);
assert_eq!(engine.sketch_selection_count(), 2);
engine.sketch_click_at(x4, y4, true);
assert_eq!(engine.sketch_selection_count(), 1);
let (ex, ey) = pixel_of(&engine, 100.0, 100.0);
engine.sketch_click_at(ex, ey, false);
assert_eq!(engine.sketch_selection_count(), 0);
}
#[test]
fn sketch_click_and_delete_a_constraint_frees_dof_and_keeps_geometry() {
let mut engine = seed_sketch_engine();
let dof_before = engine.sketch_edit_session().unwrap().diagnostics.dof;
let constraints_before = engine.sketch_constraint_count();
let points_before = engine.sketch_edit_session().unwrap().doc.points.len();
let geoms_before = engine.sketch_edit_session().unwrap().doc.geometries.len();
let wpp = engine.camera.world_per_pixel();
let seg_mid = {
let session = engine.sketch_edit_session().unwrap();
let c = session
.doc
.constraints
.iter()
.find(|c| c.raw.get("id") == Some(&serde_json::json!(5)))
.expect("constraint 5");
let segs =
crate::sketch::constraint_glyphs::constraint_glyph_segments(c, &session.doc, wpp);
let (a, b) = segs[0];
[(a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0]
};
let (px, py) = pixel_of(&engine, seg_mid[0], seg_mid[1]);
engine.sketch_click_at(px, py, false);
assert_eq!(engine.sketch_selection_count(), 1, "one thing selected");
assert_eq!(engine.sketch_selected_constraint_count(), 1, "and it is a constraint");
assert!(engine.sketch_delete_selection(), "deleted the constraint");
let session = engine.sketch_edit_session().unwrap();
assert_eq!(session.doc.points.len(), points_before, "points unchanged");
assert_eq!(session.doc.geometries.len(), geoms_before, "geometry unchanged");
assert_eq!(engine.sketch_constraint_count(), constraints_before - 1, "one fewer constraint");
assert!(
session
.doc
.constraints
.iter()
.all(|c| c.raw.get("id") != Some(&serde_json::json!(5))),
"constraint 5 is gone"
);
assert!(
session.doc.geometry(&serde_json::json!(12)).is_some(),
"referenced geometry survives (delete-constraint never deletes geometry)"
);
assert_eq!(session.diagnostics.dof, dof_before + 1, "removing a constraint frees a DOF");
assert_eq!(engine.sketch_selection_count(), 0, "selection cleared after delete");
}
#[test]
fn sketch_hover_tracks_the_entity_under_the_cursor() {
let mut engine = seed_sketch_engine();
let (x4, y4) = pixel_of(&engine, 34.0, 6.0);
assert!(engine.sketch_hover_at(x4, y4), "hover set");
assert!(!engine.sketch_hover_at(x4, y4), "unchanged hover → no churn");
let (ex, ey) = pixel_of(&engine, 100.0, 100.0);
assert!(engine.sketch_hover_at(ex, ey), "hover cleared");
assert!(engine.sketch_edit_session().unwrap().hovered.is_none());
assert!(!engine.sketch_clear_hover());
}
use crate::sketch::doc::id_key;
fn counts(engine: &EngineState) -> (usize, usize) {
let s = engine.sketch_edit_session().expect("session");
(s.doc.points.len(), s.doc.geometries.len())
}
#[test]
fn tool_set_reports_active_tool_and_clears_pending() {
let mut engine = seed_sketch_engine();
assert_eq!(engine.sketch_active_tool(), None);
engine.sketch_set_tool(Some("line"));
assert_eq!(engine.sketch_active_tool(), Some("line"));
engine.sketch_tool_place_uv(50.0, 50.0); assert_eq!(engine.sketch_pending_len(), 1);
engine.sketch_set_tool(Some("circle"));
assert_eq!(engine.sketch_active_tool(), Some("circle"));
assert_eq!(engine.sketch_pending_len(), 0);
engine.sketch_set_tool(Some("select"));
assert_eq!(engine.sketch_active_tool(), None);
}
#[test]
fn point_tool_adds_one_point_no_geometry() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
engine.sketch_set_tool(Some("point"));
engine.sketch_tool_place_uv(55.0, 45.0);
let (p1, g1) = counts(&engine);
assert_eq!(p1, p0 + 1, "one point added");
assert_eq!(g1, g0, "no geometry added");
assert_eq!(engine.sketch_pending_len(), 0, "point carries no pending");
}
#[test]
fn line_tool_chains_sharing_the_middle_point() {
let mut engine = seed_sketch_engine();
let (_, g0) = counts(&engine);
engine.sketch_set_tool(Some("line"));
engine.sketch_tool_place_uv(50.0, 50.0); assert_eq!(engine.sketch_pending_len(), 1);
engine.sketch_tool_place_uv(60.0, 50.0); engine.sketch_tool_place_uv(60.0, 60.0); assert_eq!(engine.sketch_pending_len(), 1, "chain continues from the last point");
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.geometries.len(), g0 + 2);
let n = s.doc.geometries.len();
let l1 = &s.doc.geometries[n - 2];
let l2 = &s.doc.geometries[n - 1];
assert_eq!(l1.geom_type, "line");
assert_eq!(l2.geom_type, "line");
assert_eq!(id_key(&l1.points[1]), id_key(&l2.points[0]), "middle point shared");
let pending = &engine.sketch_edit.as_ref().unwrap().pending;
assert_eq!(pending.len(), 1);
assert_eq!(id_key(&pending[0]), id_key(&l2.points[1]));
}
#[test]
fn rect_tool_places_four_points_and_a_closed_loop() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
engine.sketch_set_tool(Some("rect"));
engine.sketch_tool_place_uv(50.0, 50.0); assert_eq!(engine.sketch_pending_len(), 1);
engine.sketch_tool_place_uv(70.0, 65.0); assert_eq!(engine.sketch_pending_len(), 0);
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.points.len(), p0 + 4);
assert_eq!(s.doc.geometries.len(), g0 + 4);
let n = s.doc.geometries.len();
let loop_lines = &s.doc.geometries[n - 4..];
assert!(loop_lines.iter().all(|g| g.geom_type == "line"));
assert_eq!(
id_key(&loop_lines[0].points[0]),
id_key(&loop_lines[3].points[1]),
"rect loop not closed"
);
for w in loop_lines.windows(2) {
assert_eq!(id_key(&w[0].points[1]), id_key(&w[1].points[0]));
}
}
#[test]
fn rect_tool_adds_three_perpendicular_constraints_and_solves_clean() {
let mut engine = empty_sketch_engine();
engine.sketch_set_tool(Some("rect"));
engine.sketch_tool_place_uv(0.0, 0.0); engine.sketch_tool_place_uv(20.0, 12.0);
let s = engine.sketch_edit_session().unwrap();
let perp: Vec<_> = s
.doc
.constraints
.iter()
.filter(|c| c.ctype() == Some("⟂"))
.collect();
assert_eq!(perp.len(), 3, "rect should auto-add 3 ⟂ constraints");
assert!(perp.iter().all(|c| c.points().len() == 4));
let d = &s.diagnostics;
assert!(!d.conflicting, "sketch should not be conflicting: {d:?}");
assert_eq!(d.redundant, 0, "no redundant constraints: {d:?}");
assert_eq!(d.dof, 5, "rigid rectangle has 5 DOF: {d:?}");
}
#[test]
fn rect_stays_rectangular_when_a_corner_is_dragged() {
let mut engine = empty_sketch_engine();
engine.sketch_set_tool(Some("rect"));
engine.sketch_tool_place_uv(0.0, 0.0);
engine.sketch_tool_place_uv(20.0, 12.0);
let corner_ids: Vec<serde_json::Value> = {
let s = engine.sketch_edit_session().unwrap();
s.doc.points.iter().take(4).map(|p| p.id.clone()).collect()
};
{
let edit = engine.sketch_edit.as_mut().unwrap();
let target = corner_ids[2].clone(); let p = edit.session.doc.point_mut(&target).unwrap();
p.x = 40.0;
p.y = 30.0;
edit.session.resolve().expect("re-solve after drag");
}
let s = engine.sketch_edit_session().unwrap();
assert!(!s.diagnostics.conflicting, "still not conflicting after drag");
let coord = |id: &serde_json::Value| {
let p = s.doc.point(id).unwrap();
(p.x, p.y)
};
let c: Vec<(f64, f64)> = corner_ids.iter().map(coord).collect();
for k in 0..4 {
let prev = c[(k + 3) % 4];
let cur = c[k];
let next = c[(k + 1) % 4];
let e1 = (cur.0 - prev.0, cur.1 - prev.1);
let e2 = (next.0 - cur.0, next.1 - cur.1);
let l1 = (e1.0 * e1.0 + e1.1 * e1.1).sqrt();
let l2 = (e2.0 * e2.0 + e2.1 * e2.1).sqrt();
assert!(l1 > 1e-6 && l2 > 1e-6, "edges non-degenerate");
let cos = (e1.0 * e2.0 + e1.1 * e2.1) / (l1 * l2);
assert!(cos.abs() < 1e-3, "corner {k} not square: cos = {cos}");
}
}
#[test]
fn circle_tool_places_center_radius_and_one_circle() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
engine.sketch_set_tool(Some("circle"));
engine.sketch_tool_place_uv(50.0, 50.0); assert_eq!(engine.sketch_pending_len(), 1);
engine.sketch_tool_place_uv(56.0, 50.0); assert_eq!(engine.sketch_pending_len(), 0);
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.points.len(), p0 + 2);
assert_eq!(s.doc.geometries.len(), g0 + 1);
let g = s.doc.geometries.last().unwrap();
assert_eq!(g.geom_type, "circle");
assert_eq!(g.points.len(), 2, "circle = [center, radiusPoint]");
}
#[test]
fn arc_tool_places_center_start_end() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
engine.sketch_set_tool(Some("arc"));
engine.sketch_tool_place_uv(50.0, 50.0); engine.sketch_tool_place_uv(56.0, 50.0); assert_eq!(engine.sketch_pending_len(), 2);
engine.sketch_tool_place_uv(50.0, 56.0); assert_eq!(engine.sketch_pending_len(), 0);
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.points.len(), p0 + 3);
assert_eq!(s.doc.geometries.len(), g0 + 1);
let g = s.doc.geometries.last().unwrap();
assert_eq!(g.geom_type, "arc");
assert_eq!(g.points.len(), 3, "arc = [center, start, end]");
let center = &s.doc.geometries.last().unwrap().points[0];
let c = s.doc.point(center).unwrap();
assert!((c.x - 50.0).abs() < 1e-6 && (c.y - 50.0).abs() < 1e-6);
}
#[test]
fn draw_click_snaps_to_an_existing_point_reusing_its_id() {
let mut engine = seed_sketch_engine();
let (p0, _) = counts(&engine);
engine.sketch_set_tool(Some("line"));
engine.sketch_tool_place_uv(34.05, 6.0);
let (p1, _) = counts(&engine);
assert_eq!(p1, p0, "snapped click added no new point");
let pending = &engine.sketch_edit.as_ref().unwrap().pending;
assert_eq!(id_key(&pending[0]), id_key(&serde_json::json!(4)), "reused existing id 4");
}
#[test]
fn draw_placements_keep_the_sketch_solvable() {
let mut engine = seed_sketch_engine();
engine.sketch_set_tool(Some("line"));
engine.sketch_tool_place_uv(50.0, 50.0);
engine.sketch_tool_place_uv(60.0, 55.0);
let s = engine.sketch_edit_session().unwrap();
assert!(s.diagnostics.dof >= 0, "dof = {}", s.diagnostics.dof);
assert!(!s.diagnostics.status.is_empty(), "status populated after re-solve");
}
#[test]
fn tool_cancel_drops_the_in_progress_geometry() {
let mut engine = seed_sketch_engine();
engine.sketch_set_tool(Some("rect"));
engine.sketch_tool_place_uv(50.0, 50.0); assert_eq!(engine.sketch_pending_len(), 1);
engine.sketch_tool_cancel();
assert_eq!(engine.sketch_pending_len(), 0, "cancel cleared the buffer");
assert_eq!(engine.sketch_active_tool(), Some("rect"));
}
#[test]
fn draw_tools_no_op_when_not_in_sketch_mode() {
let mut engine = EngineState::new();
engine.sketch_set_tool(Some("line"));
assert_eq!(engine.sketch_active_tool(), None);
engine.sketch_tool_place_uv(1.0, 2.0);
engine.sketch_tool_cancel();
assert_eq!(engine.sketch_pending_len(), 0);
}
fn select_ref(engine: &mut EngineState, entity_ref: serde_json::Value) {
let edit = engine.sketch_edit.as_mut().expect("sketch edit");
edit.session.toggle_selection(entity_ref);
}
fn assert_no_dangling(engine: &EngineState) {
let s = engine.sketch_edit_session().expect("session");
let ids: std::collections::HashSet<String> =
s.doc.points.iter().map(|p| id_key(&p.id)).collect();
for g in &s.doc.geometries {
for pid in &g.points {
assert!(
ids.contains(&id_key(pid)),
"geometry {:?} references missing point {:?}",
g.id,
pid
);
}
}
}
#[test]
fn bezier_tool_places_curve_plus_two_construction_guides() {
let mut engine = seed_sketch_engine();
let (p0c, g0c) = counts(&engine);
engine.sketch_set_tool(Some("bezier"));
assert_eq!(engine.sketch_active_tool(), Some("bezier"));
engine.sketch_tool_place_uv(50.0, 50.0); assert_eq!(engine.sketch_pending_len(), 1);
engine.sketch_tool_place_uv(54.0, 58.0); engine.sketch_tool_place_uv(62.0, 58.0); assert_eq!(engine.sketch_pending_len(), 3);
engine.sketch_tool_place_uv(66.0, 50.0); assert_eq!(engine.sketch_pending_len(), 0, "pending cleared on commit");
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.points.len(), p0c + 4, "four control points added");
assert_eq!(s.doc.geometries.len(), g0c + 3, "bezier + 2 guide lines");
let n = s.doc.geometries.len();
let bez = &s.doc.geometries[n - 3];
let l1 = &s.doc.geometries[n - 2];
let l2 = &s.doc.geometries[n - 1];
assert_eq!(bez.geom_type, "bezier");
assert_eq!(bez.points.len(), 4, "bezier = [p0, p1, p2, p3]");
assert!(!bez.construction(), "the bezier span is a profile edge");
assert_eq!(l1.geom_type, "line");
assert_eq!(l2.geom_type, "line");
assert!(l1.construction() && l2.construction(), "guides are construction");
assert_eq!(id_key(&l1.points[0]), id_key(&bez.points[0]));
assert_eq!(id_key(&l1.points[1]), id_key(&bez.points[1]));
assert_eq!(id_key(&l2.points[0]), id_key(&bez.points[3]));
assert_eq!(id_key(&l2.points[1]), id_key(&bez.points[2]));
assert_ne!(id_key(&bez.id), id_key(&l1.id));
assert_ne!(id_key(&l1.id), id_key(&l2.id));
assert!(!s.diagnostics.status.is_empty(), "status populated after re-solve");
}
#[test]
fn delete_removes_selected_circle_and_orphaned_points_keeps_rect() {
let mut engine = seed_sketch_engine();
let (p0c, g0c) = counts(&engine);
select_ref(&mut engine, crate::sketch::geometry_ref(&serde_json::json!(20)));
select_ref(&mut engine, crate::sketch::point_ref(&serde_json::json!(4)));
select_ref(&mut engine, crate::sketch::point_ref(&serde_json::json!(5)));
assert_eq!(engine.sketch_selection_count(), 3);
assert!(engine.sketch_delete_selection(), "deleted something");
assert_eq!(engine.sketch_selection_count(), 0, "selection cleared");
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.points.len(), p0c - 2, "circle center + radius removed");
assert_eq!(s.doc.geometries.len(), g0c - 1, "circle geom removed");
assert!(s.doc.point(&serde_json::json!(4)).is_none());
assert!(s.doc.point(&serde_json::json!(5)).is_none());
assert!(s.doc.geometries.iter().all(|g| g.geom_type == "line"), "only rect lines remain");
assert_eq!(s.diagnostics.dof, 0, "rect fully constrained");
assert_no_dangling(&engine);
}
#[test]
fn delete_rect_line_keeps_shared_corners() {
let mut engine = seed_sketch_engine();
let (p0c, g0c) = counts(&engine);
select_ref(&mut engine, crate::sketch::geometry_ref(&serde_json::json!(10)));
assert!(engine.sketch_delete_selection());
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.geometries.len(), g0c - 1, "one line removed");
assert_eq!(s.doc.points.len(), p0c, "shared corners survive");
assert!(s.doc.point(&serde_json::json!(0)).is_some(), "corner 0 shared → kept");
assert!(s.doc.point(&serde_json::json!(1)).is_some(), "corner 1 shared → kept");
assert_no_dangling(&engine);
}
#[test]
fn delete_standalone_line_orphans_both_endpoints() {
let mut engine = seed_sketch_engine();
let (p0c, g0c) = counts(&engine);
engine.sketch_set_tool(Some("line"));
engine.sketch_tool_place_uv(80.0, 80.0);
engine.sketch_tool_place_uv(90.0, 88.0);
engine.sketch_set_tool(Some("select"));
let new_geo_id = {
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.geometries.len(), g0c + 1);
assert_eq!(s.doc.points.len(), p0c + 2);
s.doc.geometries.last().unwrap().id.clone()
};
select_ref(&mut engine, crate::sketch::geometry_ref(&new_geo_id));
assert!(engine.sketch_delete_selection());
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.geometries.len(), g0c, "line removed");
assert_eq!(s.doc.points.len(), p0c, "both orphaned endpoints removed");
assert_no_dangling(&engine);
}
#[test]
fn delete_shared_vertex_drops_dependent_geometry_no_dangling() {
let mut engine = seed_sketch_engine();
select_ref(&mut engine, crate::sketch::point_ref(&serde_json::json!(1)));
select_ref(&mut engine, crate::sketch::geometry_ref(&serde_json::json!(20)));
assert!(engine.sketch_delete_selection());
let s = engine.sketch_edit_session().unwrap();
assert!(s.doc.point(&serde_json::json!(1)).is_none(), "selected vertex removed");
assert!(s.doc.geometries.iter().all(|g| id_key(&g.id) != id_key(&serde_json::json!(10))));
assert!(s.doc.geometries.iter().all(|g| id_key(&g.id) != id_key(&serde_json::json!(11))));
assert!(s.doc.geometries.iter().all(|g| id_key(&g.id) != id_key(&serde_json::json!(20))));
assert_no_dangling(&engine);
}
#[test]
fn delete_no_op_without_selection_or_sketch() {
let mut engine = seed_sketch_engine();
assert!(!engine.sketch_delete_selection());
let mut bare = EngineState::new();
assert!(!bare.sketch_delete_selection());
}
use crate::sketch::{geometry_ref, point_ref};
fn engine_with_sketch(sketch: serde_json::Value) -> EngineState {
let hist = serde_json::json!({
"features": [{
"type": "S",
"inputParams": { "id": "Sk" },
"persistentData": {
"basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
"sketch": sketch
}
}]
})
.to_string();
let mut engine = EngineState::new();
engine.set_history_json(&hist).unwrap();
engine.enter_sketch_mode("Sk").expect("enter sketch");
engine
}
fn palette_symbols(engine: &EngineState) -> Vec<String> {
engine
.sketch_applicable_constraints()
.into_iter()
.map(|a| a.symbol)
.collect()
}
fn last_constraint_type(engine: &EngineState) -> String {
let s = engine.sketch_edit_session().unwrap();
s.doc.constraints.last().unwrap().ctype().unwrap().to_string()
}
fn last_constraint_keys(engine: &EngineState) -> Vec<String> {
let s = engine.sketch_edit_session().unwrap();
s.doc.constraints.last().unwrap().points().iter().map(id_key).collect()
}
fn constraint_keys_at(engine: &EngineState, i: usize) -> Vec<String> {
let s = engine.sketch_edit_session().unwrap();
s.doc.constraints[i].points().iter().map(id_key).collect()
}
fn count_ctype(engine: &EngineState, t: &str) -> usize {
let s = engine.sketch_edit_session().unwrap();
s.doc.constraints.iter().filter(|c| c.ctype() == Some(t)).count()
}
fn free_point(id: i64, x: f64, y: f64) -> serde_json::Value {
serde_json::json!({ "id": id, "x": x, "y": y })
}
#[test]
fn palette_one_point_offers_ground() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [free_point(0, 0.0, 0.0), free_point(1, 5.0, 5.0)],
"geometries": [], "constraints": []
}));
select_ref(&mut engine, point_ref(&serde_json::json!(1)));
assert_eq!(palette_symbols(&engine), vec!["⏚"]);
}
#[test]
fn palette_two_points_offers_horizontal_vertical_coincident_distance() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [free_point(0, 0.0, 0.0), free_point(1, 5.0, 0.0), free_point(2, 5.0, 5.0)],
"geometries": [], "constraints": []
}));
select_ref(&mut engine, point_ref(&serde_json::json!(1)));
select_ref(&mut engine, point_ref(&serde_json::json!(2)));
assert_eq!(palette_symbols(&engine), vec!["━", "│", "≡", "⟺"]);
}
#[test]
fn palette_two_lines_offers_the_line_pair_set() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0),
free_point(3, 0.0, 5.0), free_point(4, 10.0, 6.0)
],
"geometries": [
{ "id": 10, "type": "line", "points": [1, 2] },
{ "id": 11, "type": "line", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert_eq!(palette_symbols(&engine), vec!["∥", "⟂", "∠", "⇌", "⋰", "⏛"]);
}
#[test]
fn palette_two_circles_offers_equal_radius_concentric_tangent() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 3.0, 0.0),
free_point(3, 10.0, 0.0), free_point(4, 14.0, 0.0)
],
"geometries": [
{ "id": 10, "type": "circle", "points": [1, 2] },
{ "id": 11, "type": "circle", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert_eq!(palette_symbols(&engine), vec!["⊜", "◎", "⌒"]);
}
#[test]
fn palette_line_and_circle_offers_tangent_only() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0),
free_point(3, 5.0, 8.0), free_point(4, 7.0, 8.0)
],
"geometries": [
{ "id": 10, "type": "line", "points": [1, 2] },
{ "id": 11, "type": "circle", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert_eq!(palette_symbols(&engine), vec!["⌒"]);
}
#[test]
fn palette_line_plus_point_offers_midpoint_pol_dist_angle() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0), free_point(3, 5.0, 5.0)
],
"geometries": [{ "id": 10, "type": "line", "points": [1, 2] }],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, point_ref(&serde_json::json!(3)));
assert_eq!(palette_symbols(&engine), vec!["⋯", "⏛", "↥", "∠"]);
}
#[test]
fn palette_three_real_points_also_offers_collinear() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 4.0, 1.0), free_point(3, 8.0, 2.0)
],
"geometries": [], "constraints": []
}));
select_ref(&mut engine, point_ref(&serde_json::json!(1)));
select_ref(&mut engine, point_ref(&serde_json::json!(2)));
select_ref(&mut engine, point_ref(&serde_json::json!(3)));
assert_eq!(palette_symbols(&engine), vec!["⋯", "⏛", "↥", "∠", "⋰"]);
}
#[test]
fn palette_one_line_two_points_offers_perp_and_symmetric() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0),
free_point(3, 3.0, 5.0), free_point(4, 7.0, 5.0)
],
"geometries": [{ "id": 10, "type": "line", "points": [1, 2] }],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, point_ref(&serde_json::json!(3)));
select_ref(&mut engine, point_ref(&serde_json::json!(4)));
assert_eq!(palette_symbols(&engine), vec!["⟂", "⋈"]);
}
#[test]
fn palette_one_circle_offers_radius_diameter() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [free_point(1, 0.0, 0.0), free_point(2, 4.0, 0.0)],
"geometries": [{ "id": 10, "type": "circle", "points": [1, 2] }],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
assert_eq!(palette_symbols(&engine), vec!["R", "⌀"]);
}
#[test]
fn palette_empty_selection_is_empty() {
let engine = engine_with_sketch(serde_json::json!({
"points": [free_point(0, 0.0, 0.0)], "geometries": [], "constraints": []
}));
assert!(engine.sketch_applicable_constraints().is_empty());
assert!(EngineState::new().sketch_applicable_constraints().is_empty());
}
#[test]
fn add_radius_on_arc_pops_the_end_point() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 4.0, 0.0), free_point(3, 0.0, 4.0)
],
"geometries": [{ "id": 10, "type": "arc", "points": [1, 2, 3] }],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
assert!(engine.sketch_add_constraint("R"));
assert_eq!(last_constraint_type(&engine), "⟺");
assert_eq!(last_constraint_keys(&engine), vec!["1", "2"]);
let s = engine.sketch_edit_session().unwrap();
let display = s.doc.constraints.last().unwrap().raw.get("displayStyle");
assert_eq!(display.and_then(|v| v.as_str()), Some("radius"));
}
#[test]
fn add_diameter_sets_diameter_display_style() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [free_point(1, 0.0, 0.0), free_point(2, 4.0, 0.0)],
"geometries": [{ "id": 10, "type": "circle", "points": [1, 2] }],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
assert!(engine.sketch_add_constraint("⌀"));
assert_eq!(last_constraint_type(&engine), "⟺");
let s = engine.sketch_edit_session().unwrap();
let display = s.doc.constraints.last().unwrap().raw.get("displayStyle");
assert_eq!(display.and_then(|v| v.as_str()), Some("diameter"));
assert!(s.doc.constraints.last().unwrap().raw.get("valueNeedsSetup").is_some());
}
#[test]
fn add_concentric_uses_the_two_centers() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 3.0, 0.0),
free_point(3, 10.0, 0.0), free_point(4, 14.0, 0.0)
],
"geometries": [
{ "id": 10, "type": "circle", "points": [1, 2] },
{ "id": 11, "type": "circle", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert!(engine.sketch_add_constraint("◎"));
assert_eq!(last_constraint_type(&engine), "◎");
assert_eq!(last_constraint_keys(&engine), vec!["1", "3"]);
}
#[test]
fn add_equal_radius_uses_both_center_and_boundary() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 3.0, 0.0),
free_point(3, 10.0, 0.0), free_point(4, 14.0, 0.0)
],
"geometries": [
{ "id": 10, "type": "circle", "points": [1, 2] },
{ "id": 11, "type": "circle", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert!(engine.sketch_add_constraint("⊜"));
assert_eq!(last_constraint_type(&engine), "⊜");
assert_eq!(last_constraint_keys(&engine), vec!["1", "2", "3", "4"]);
}
#[test]
fn add_tangent_line_circle_orders_line_then_center_boundary() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0),
free_point(3, 5.0, 8.0), free_point(4, 7.0, 8.0)
],
"geometries": [
{ "id": 10, "type": "line", "points": [1, 2] },
{ "id": 11, "type": "circle", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert!(engine.sketch_add_constraint("⌒"));
assert_eq!(last_constraint_type(&engine), "⌒");
assert_eq!(last_constraint_keys(&engine), vec!["1", "2", "3", "4"]);
}
#[test]
fn add_collinear_from_two_lines_flattens_endpoints() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0),
free_point(3, 20.0, 1.0), free_point(4, 30.0, 1.0)
],
"geometries": [
{ "id": 10, "type": "line", "points": [1, 2] },
{ "id": 11, "type": "line", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert!(engine.sketch_add_constraint("⋰"));
assert_eq!(last_constraint_type(&engine), "⋰");
assert_eq!(last_constraint_keys(&engine), vec!["1", "2", "3", "4"]);
}
#[test]
fn add_symmetric_orders_axis_then_the_two_points() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 0.0, 10.0),
free_point(3, -3.0, 5.0), free_point(4, 3.0, 5.0)
],
"geometries": [{ "id": 10, "type": "line", "points": [1, 2] }],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, point_ref(&serde_json::json!(3)));
select_ref(&mut engine, point_ref(&serde_json::json!(4)));
assert!(engine.sketch_add_constraint("⋈"));
assert_eq!(last_constraint_type(&engine), "⋈");
assert_eq!(last_constraint_keys(&engine), vec!["1", "2", "3", "4"]);
}
#[test]
fn add_midpoint_reverses_when_point_selected_before_line() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0), free_point(3, 5.0, 0.0)
],
"geometries": [{ "id": 10, "type": "line", "points": [1, 2] }],
"constraints": []
}));
select_ref(&mut engine, point_ref(&serde_json::json!(3)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
assert!(engine.sketch_add_constraint("⋯"));
assert_eq!(last_constraint_type(&engine), "⋯");
assert_eq!(last_constraint_keys(&engine), vec!["2", "1", "3"]);
}
#[test]
fn add_point_line_distance_builds_line_then_point() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0), free_point(3, 5.0, 4.0)
],
"geometries": [{ "id": 10, "type": "line", "points": [1, 2] }],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, point_ref(&serde_json::json!(3)));
assert!(engine.sketch_add_constraint("↥"));
assert_eq!(last_constraint_type(&engine), "↥");
assert_eq!(last_constraint_keys(&engine), vec!["1", "2", "3"]);
}
#[test]
fn add_perpendicular_swaps_first_pair_for_orientation() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 1.0, -5.0),
free_point(3, 2.0, 0.0), free_point(4, 12.0, 0.0)
],
"geometries": [
{ "id": 10, "type": "line", "points": [1, 2] },
{ "id": 11, "type": "line", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert!(engine.sketch_add_constraint("⟂"));
assert_eq!(last_constraint_type(&engine), "⟂");
assert_eq!(last_constraint_keys(&engine), vec!["2", "1", "3", "4"]);
}
#[test]
fn add_point_on_line_from_two_lines_pushes_two_constraints() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0),
free_point(3, 3.0, 5.0), free_point(4, 7.0, 6.0)
],
"geometries": [
{ "id": 10, "type": "line", "points": [1, 2] },
{ "id": 11, "type": "line", "points": [3, 4] }
],
"constraints": []
}));
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
let before = engine.sketch_constraint_count();
assert!(engine.sketch_add_constraint("⏛"));
assert_eq!(engine.sketch_constraint_count(), before + 2, "two point-on-line constraints");
let n = engine.sketch_constraint_count();
assert_eq!(constraint_keys_at(&engine, n - 2), vec!["1", "2", "3"]);
assert_eq!(constraint_keys_at(&engine, n - 1), vec!["1", "2", "4"]);
}
#[test]
fn add_constraint_dedups_identical_type_and_points() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [free_point(1, 0.0, 0.0), free_point(2, 5.0, 0.0)],
"geometries": [], "constraints": []
}));
select_ref(&mut engine, point_ref(&serde_json::json!(1)));
select_ref(&mut engine, point_ref(&serde_json::json!(2)));
assert!(engine.sketch_add_constraint("━"), "first add succeeds");
assert!(!engine.sketch_add_constraint("━"), "second add is a dedup no-op");
assert_eq!(count_ctype(&engine, "━"), 1, "only one horizontal constraint");
}
#[test]
fn add_horizontal_reduces_dof() {
let mut engine = seed_sketch_engine();
let before = engine.sketch_edit_session().unwrap().diagnostics.dof;
select_ref(&mut engine, point_ref(&serde_json::json!(4)));
select_ref(&mut engine, point_ref(&serde_json::json!(5)));
assert!(engine.sketch_add_constraint("━"));
let after = engine.sketch_edit_session().unwrap();
assert_eq!(after.diagnostics.dof, before - 1, "horizontal removes one DOF");
assert!(!after.diagnostics.status.is_empty(), "still solves");
}
#[test]
fn add_coincident_reduces_two_dof() {
let mut engine = seed_sketch_engine();
let before = engine.sketch_edit_session().unwrap().diagnostics.dof;
select_ref(&mut engine, point_ref(&serde_json::json!(4)));
select_ref(&mut engine, point_ref(&serde_json::json!(5)));
assert!(engine.sketch_add_constraint("≡"));
let after = engine.sketch_edit_session().unwrap().diagnostics.dof;
assert_eq!(after, before - 2, "coincident pins both coordinates");
}
#[test]
fn add_perpendicular_reduces_dof() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(1, 0.0, 0.0), free_point(2, 10.0, 1.0),
free_point(3, 0.0, 5.0), free_point(4, 8.0, 5.0)
],
"geometries": [
{ "id": 10, "type": "line", "points": [1, 2] },
{ "id": 11, "type": "line", "points": [3, 4] }
],
"constraints": [{ "id": 0, "type": "⏚", "points": [1] }]
}));
let before = engine.sketch_edit_session().unwrap().diagnostics.dof;
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
select_ref(&mut engine, geometry_ref(&serde_json::json!(11)));
assert!(engine.sketch_add_constraint("⟂"));
let after = engine.sketch_edit_session().unwrap().diagnostics.dof;
assert_eq!(after, before - 1, "perpendicular removes one DOF");
}
#[test]
fn toggle_ground_adds_then_removes_the_ground() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [free_point(0, 0.0, 0.0), free_point(1, 5.0, 5.0)],
"geometries": [], "constraints": []
}));
select_ref(&mut engine, point_ref(&serde_json::json!(1)));
assert_eq!(engine.sketch_selection_all_grounded(), Some(false));
assert!(engine.sketch_toggle_ground());
assert_eq!(engine.sketch_selection_all_grounded(), Some(true));
assert_eq!(count_ctype(&engine, "⏚"), 1);
assert!(engine.sketch_edit_session().unwrap().doc.point(&serde_json::json!(1)).unwrap().fixed);
assert!(engine.sketch_toggle_ground());
assert_eq!(engine.sketch_selection_all_grounded(), Some(false));
assert_eq!(count_ctype(&engine, "⏚"), 0);
assert!(!engine.sketch_edit_session().unwrap().doc.point(&serde_json::json!(1)).unwrap().fixed);
}
#[test]
fn toggle_construction_flips_points_and_geometry() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [free_point(1, 0.0, 0.0), free_point(2, 10.0, 0.0)],
"geometries": [{ "id": 10, "type": "line", "points": [1, 2] }],
"constraints": []
}));
select_ref(&mut engine, point_ref(&serde_json::json!(1)));
assert_eq!(engine.sketch_selection_all_construction(), Some(false));
assert!(engine.sketch_toggle_construction());
assert!(engine.sketch_edit_session().unwrap().doc.point(&serde_json::json!(1)).unwrap().construction);
assert_eq!(engine.sketch_selection_all_construction(), Some(true));
assert!(engine.sketch_toggle_construction());
assert!(!engine.sketch_edit_session().unwrap().doc.point(&serde_json::json!(1)).unwrap().construction);
engine.sketch_edit.as_mut().unwrap().session.clear_selection();
select_ref(&mut engine, geometry_ref(&serde_json::json!(10)));
assert!(engine.sketch_toggle_construction());
assert!(engine.sketch_edit_session().unwrap().doc.geometry(&serde_json::json!(10)).unwrap().construction());
}
#[test]
fn cleanup_unused_points_drops_only_the_stray() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
free_point(0, 0.0, 0.0), free_point(1, 10.0, 0.0),
free_point(2, 50.0, 50.0), free_point(3, 20.0, 20.0)
],
"geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
"constraints": [{ "id": 0, "type": "⏚", "points": [3] }]
}));
assert!(engine.sketch_cleanup_unused_points());
let s = engine.sketch_edit_session().unwrap();
assert!(s.doc.point(&serde_json::json!(2)).is_none(), "stray removed");
assert!(s.doc.point(&serde_json::json!(3)).is_some(), "constraint-referenced kept");
assert!(s.doc.point(&serde_json::json!(0)).is_some() && s.doc.point(&serde_json::json!(1)).is_some());
assert!(!engine.sketch_cleanup_unused_points());
}
#[test]
fn add_constraint_no_op_when_not_in_sketch_mode() {
let mut bare = EngineState::new();
assert!(!bare.sketch_add_constraint("━"));
assert!(!bare.sketch_toggle_ground());
assert!(!bare.sketch_toggle_construction());
assert!(!bare.sketch_cleanup_unused_points());
assert_eq!(bare.sketch_constraint_count(), 0);
assert_eq!(bare.sketch_selection_all_grounded(), None);
assert_eq!(bare.sketch_selection_all_construction(), None);
}
fn point_distance(engine: &EngineState, a: i64, b: i64) -> f64 {
let s = engine.sketch_edit_session().unwrap();
let pa = s.doc.point(&serde_json::json!(a)).unwrap();
let pb = s.doc.point(&serde_json::json!(b)).unwrap();
(pa.x - pb.x).hypot(pa.y - pb.y)
}
fn engine_with_sketch_and_expr(sketch: serde_json::Value, expressions: &str) -> EngineState {
let hist = serde_json::json!({
"expressions": expressions,
"features": [{
"type": "S",
"inputParams": { "id": "Sk" },
"persistentData": {
"basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
"sketch": sketch
}
}]
})
.to_string();
let mut engine = EngineState::new();
engine.set_history_json(&hist).unwrap();
engine.enter_sketch_mode("Sk").expect("enter sketch");
engine
}
fn distance_dim_sketch() -> serde_json::Value {
serde_json::json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0, "fixed": true },
{ "id": 1, "x": 6.0, "y": 0.0 }
],
"geometries": [{ "id": 10, "type": "line", "points": [0, 1] }],
"constraints": [
{ "id": 0, "type": "⏚", "points": [0] },
{ "id": 1, "type": "━", "points": [0, 1] },
{ "id": 2, "type": "⟺", "points": [0, 1], "value": null,
"displayStyle": "", "valueNeedsSetup": true }
]
})
}
#[test]
fn dimension_labels_and_leaders_render_for_a_distance_dim() {
let engine = engine_with_sketch(distance_dim_sketch());
let labels: serde_json::Value =
serde_json::from_str(&engine.sketch_dimension_labels_json()).unwrap();
let arr = labels.as_array().unwrap();
assert_eq!(arr.len(), 1, "one dim label: {arr:?}");
assert_eq!(arr[0]["mode"], "distance");
let world = arr[0]["world"].as_array().unwrap();
assert!(world.iter().all(|v| v.as_f64().unwrap().is_finite()));
assert!((world[0].as_f64().unwrap() - 3.0).abs() < 1e-6);
let session = engine.sketch_edit_session().unwrap();
let leaders: serde_json::Value =
serde_json::from_str(&session.dim_leaders_overlay_json(0.05)).unwrap();
let group = &leaders["groups"][0];
assert_eq!(group["name"], "sketch-dim-leaders");
assert!(
!group["lines"]["positions"].as_array().unwrap().is_empty(),
"leader group should carry segments"
);
}
#[test]
fn set_dimension_value_plain_number_resolves_and_drops_expr() {
let mut engine = engine_with_sketch(distance_dim_sketch());
{
let edit = engine.sketch_edit.as_mut().unwrap();
edit.session.doc.constraints[2]
.raw
.insert("valueExpr".into(), serde_json::json!("stale"));
}
assert!(engine.sketch_set_dimension_value(&serde_json::json!(2), "15"));
assert!((point_distance(&engine, 0, 1) - 15.0).abs() < 1e-6);
let s = engine.sketch_edit_session().unwrap();
let c = &s.doc.constraints[2].raw;
assert_eq!(c.get("value").and_then(|v| v.as_f64()), Some(15.0));
assert!(c.get("valueExpr").is_none(), "valueExpr dropped for a plain number");
assert_eq!(c.get("valueNeedsSetup").and_then(|v| v.as_bool()), Some(false));
}
#[test]
fn set_dimension_value_expression_binds_history_variable() {
let mut engine =
engine_with_sketch_and_expr(distance_dim_sketch(), "width = 20;");
assert!(engine.sketch_set_dimension_value(&serde_json::json!(2), "width/2"));
assert!((point_distance(&engine, 0, 1) - 10.0).abs() < 1e-6);
let s = engine.sketch_edit_session().unwrap();
let c = &s.doc.constraints[2].raw;
assert_eq!(c.get("value").and_then(|v| v.as_f64()), Some(10.0));
assert_eq!(c.get("valueExpr").and_then(|v| v.as_str()), Some("width/2"));
assert!(engine.sketch_set_dimension_value(&serde_json::json!(2), "10+5"));
assert!((point_distance(&engine, 0, 1) - 15.0).abs() < 1e-6);
}
#[test]
fn set_dimension_value_bad_expression_keeps_old_value() {
let mut engine = engine_with_sketch(distance_dim_sketch());
assert!(engine.sketch_set_dimension_value(&serde_json::json!(2), "12"));
assert!((point_distance(&engine, 0, 1) - 12.0).abs() < 1e-6);
assert!(!engine.sketch_set_dimension_value(&serde_json::json!(2), "bogus*2"));
assert!((point_distance(&engine, 0, 1) - 12.0).abs() < 1e-6);
let s = engine.sketch_edit_session().unwrap();
assert_eq!(
s.doc.constraints[2].raw.get("value").and_then(|v| v.as_f64()),
Some(12.0)
);
}
#[test]
fn set_diameter_value_halves_into_the_solver_radius() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0, "fixed": true },
{ "id": 1, "x": 3.0, "y": 0.0 }
],
"geometries": [{ "id": 10, "type": "circle", "points": [0, 1] }],
"constraints": [
{ "id": 0, "type": "⏚", "points": [0] },
{ "id": 5, "type": "⟺", "points": [0, 1], "value": null,
"displayStyle": "diameter", "valueNeedsSetup": true }
]
}));
assert!(engine.sketch_set_dimension_value(&serde_json::json!(5), "10"));
assert!((point_distance(&engine, 0, 1) - 5.0).abs() < 1e-6);
let s = engine.sketch_edit_session().unwrap();
assert_eq!(
s.doc.constraints[1].raw.get("value").and_then(|v| v.as_f64()),
Some(5.0)
);
let seed: serde_json::Value =
serde_json::from_str(&engine.sketch_dimension_value_json(&serde_json::json!(5)))
.unwrap();
assert_eq!(seed["value"].as_f64(), Some(10.0));
assert_eq!(seed["mode"], "diameter");
}
#[test]
fn drag_to_updates_the_offset_for_the_constraint() {
let mut engine = engine_with_sketch(distance_dim_sketch());
engine.resize(800.0, 600.0);
engine.camera.eye = [3.0, 0.0, 40.0];
engine.camera.target = [3.0, 0.0, 0.0];
engine.camera.up = [0.0, 1.0, 0.0];
engine.camera.projection = crate::view::Projection::Orthographic { half_height: 20.0 };
let world = engine
.sketch_edit_session()
.unwrap()
.plane
.to_world(3.0, 5.0);
let (sx, sy, _) = engine.camera.project(world);
engine.sketch_dimension_drag_to(&serde_json::json!(2), sx, sy);
let s = engine.sketch_edit_session().unwrap();
let off = s.dim_offsets.get("2").expect("offset stored for cid 2");
assert!((off["du"].as_f64().unwrap()).abs() < 1e-3, "du = {off}");
assert!((off["dv"].as_f64().unwrap() - 5.0).abs() < 1e-3, "dv = {off}");
}
#[test]
fn commit_persists_dim_offsets_and_edited_value_then_reenter_loads_them() {
let mut engine = engine_with_sketch(distance_dim_sketch());
assert!(engine.sketch_set_dimension_value(&serde_json::json!(2), "9"));
{
let edit = engine.sketch_edit.as_mut().unwrap();
edit.session
.dim_offsets
.insert("2".into(), serde_json::json!({ "du": 1.5, "dv": 2.5 }));
}
engine.exit_sketch_mode(true);
assert!(!engine.sketch_mode());
let index = engine.history.index_of("Sk").unwrap();
let pd = engine.history.feature_persistent_data(index).unwrap();
let off = &pd["dimOffsets"]["2"];
assert_eq!(off["du"].as_f64(), Some(1.5));
assert_eq!(off["dv"].as_f64(), Some(2.5));
engine.enter_sketch_mode("Sk").expect("re-enter");
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.dim_offsets.get("2").and_then(|o| o["du"].as_f64()), Some(1.5));
assert!((point_distance(&engine, 0, 1) - 9.0).abs() < 1e-6);
}
#[test]
fn is_plain_number_literal_matches_the_dom_regex() {
for ok in ["1", "-2", "3.5", ".5", "-.5", "10", "1e3", "2.5e-2", "+4"] {
assert!(is_plain_number_literal(ok), "{ok} should be a plain number");
}
for expr in ["width", "1+2", "width/2", "", "e3", "1.", "abc", "1e"] {
assert!(!is_plain_number_literal(expr), "{expr} should NOT be a plain number");
}
}
#[test]
fn dimension_editing_no_ops_when_not_in_sketch_mode() {
let mut bare = EngineState::new();
assert_eq!(bare.sketch_dimension_labels_json(), "[]");
assert_eq!(bare.sketch_dimension_value_json(&serde_json::json!(0)), "{}");
assert!(!bare.sketch_set_dimension_value(&serde_json::json!(0), "5"));
bare.sketch_dimension_drag_to(&serde_json::json!(0), 1.0, 1.0); }
fn empty_sketch_engine() -> EngineState {
engine_with_sketch(serde_json::json!({
"points": [], "geometries": [], "constraints": []
}))
}
#[test]
fn drawing_a_line_is_undoable_and_redoable() {
let mut engine = empty_sketch_engine();
assert!(!engine.sketch_can_undo(), "fresh session has no undo");
engine.sketch_set_tool(Some("line"));
engine.sketch_tool_place_uv(0.0, 0.0); engine.sketch_tool_place_uv(10.0, 0.0); assert_eq!(counts(&engine), (2, 1), "a line + its two endpoints");
assert!(engine.sketch_can_undo());
assert!(!engine.sketch_can_redo());
assert!(engine.sketch_undo());
assert_eq!(counts(&engine), (1, 0), "line undone");
assert!(engine.sketch_can_redo());
assert!(engine.sketch_undo());
assert_eq!(counts(&engine), (0, 0), "anchor undone");
assert!(!engine.sketch_can_undo(), "stack exhausted");
assert!(engine.sketch_redo());
assert_eq!(counts(&engine), (1, 0));
assert!(engine.sketch_redo());
assert_eq!(counts(&engine), (2, 1), "line restored");
assert!(!engine.sketch_can_redo());
}
#[test]
fn a_new_mutation_clears_the_redo_stack() {
let mut engine = empty_sketch_engine();
engine.sketch_set_tool(Some("point"));
engine.sketch_tool_place_uv(1.0, 1.0);
engine.sketch_tool_place_uv(2.0, 2.0);
assert!(engine.sketch_undo()); assert!(engine.sketch_can_redo());
engine.sketch_tool_place_uv(3.0, 3.0);
assert!(!engine.sketch_can_redo(), "new op cleared redo");
}
#[test]
fn adding_a_constraint_is_undoable_and_a_dedup_no_op_leaves_no_step() {
let mut engine = engine_with_sketch(serde_json::json!({
"points": [free_point(1, 0.0, 0.0), free_point(2, 5.0, 0.0)],
"geometries": [], "constraints": []
}));
select_ref(&mut engine, point_ref(&serde_json::json!(1)));
select_ref(&mut engine, point_ref(&serde_json::json!(2)));
assert!(engine.sketch_add_constraint("━"));
assert_eq!(count_ctype(&engine, "━"), 1);
assert!(engine.sketch_can_undo());
assert!(!engine.sketch_add_constraint("━"));
assert_eq!(count_ctype(&engine, "━"), 1);
assert!(engine.sketch_undo());
assert_eq!(count_ctype(&engine, "━"), 0);
assert!(!engine.sketch_can_undo(), "exactly one undo step");
}
#[test]
fn a_point_drag_gesture_is_one_undo_step() {
let mut engine = seed_sketch_engine();
let (px, py) = pixel_of(&engine, 34.0, 6.0);
assert!(engine.sketch_drag_begin(px, py), "grabbed point 4");
for (u, v) in [(35.0, 6.0), (37.0, 7.0), (38.0, 8.0)] {
let (mx, my) = pixel_of(&engine, u, v);
engine.sketch_drag_to(mx, my);
}
engine.sketch_drag_end();
let moved = engine
.sketch_edit_session()
.unwrap()
.doc
.point(&serde_json::json!(4))
.unwrap()
.clone();
assert!((moved.x - 34.0).abs() > 1.0, "the point actually moved");
assert!(engine.sketch_undo());
let back = engine
.sketch_edit_session()
.unwrap()
.doc
.point(&serde_json::json!(4))
.unwrap()
.clone();
assert!((back.x - 34.0).abs() < 1e-6 && (back.y - 6.0).abs() < 1e-6, "restored");
assert!(!engine.sketch_can_undo(), "the whole drag was a single step");
}
#[test]
fn enter_sketch_mode_starts_with_empty_undo_redo_stacks() {
let mut engine = empty_sketch_engine();
engine.sketch_set_tool(Some("point"));
engine.sketch_tool_place_uv(1.0, 1.0);
assert!(engine.sketch_can_undo());
engine.exit_sketch_mode(true);
engine.enter_sketch_mode("Sk").expect("re-enter");
assert!(!engine.sketch_can_undo(), "enter clears undo");
assert!(!engine.sketch_can_redo(), "enter clears redo");
}
#[test]
fn undo_redo_are_no_ops_outside_sketch_mode() {
let mut bare = EngineState::new();
assert!(!bare.sketch_undo());
assert!(!bare.sketch_redo());
assert!(!bare.sketch_can_undo());
assert!(!bare.sketch_can_redo());
}
#[test]
fn edit_existing_enter_loads_persisted_then_commit_writes_modification() {
let mut engine = EngineState::new();
engine.set_history_json(&seed_sketch_history()).unwrap();
engine.enter_sketch_mode("Sk").expect("enter existing sketch");
assert!(engine.sketch_mode());
assert_eq!(engine.sketch_edit_feature_id(), Some("Sk"));
assert_eq!(counts(&engine), (6, 5), "session matches the persisted doc");
engine.sketch_set_tool(Some("line"));
engine.sketch_tool_place_uv(50.0, 50.0);
engine.sketch_tool_place_uv(60.0, 50.0);
assert_eq!(counts(&engine), (8, 6), "a new line landed");
engine.exit_sketch_mode(true);
assert!(!engine.sketch_mode());
let index = engine.history.index_of("Sk").unwrap();
let pd = engine.history.feature_persistent_data(index).unwrap();
assert_eq!(pd["sketch"]["geometries"].as_array().unwrap().len(), 6);
}
#[test]
fn diagnostics_dump_reports_the_persisted_sketch() {
let mut engine = EngineState::new();
engine.set_history_json(&seed_sketch_history()).unwrap();
let dump: serde_json::Value =
serde_json::from_str(&engine.sketch_diagnostics_dump_json("Sk")).unwrap();
assert_eq!(dump["featureId"], "Sk");
assert_eq!(dump["pointCount"], 6);
assert_eq!(dump["geometryCount"], 5);
assert!(dump["diagnostics"]["dof"].as_i64().is_some());
let err: serde_json::Value =
serde_json::from_str(&engine.sketch_diagnostics_dump_json("Nope")).unwrap();
assert!(err.get("error").is_some());
}
fn trim_sketch_history() -> String {
serde_json::json!({
"features": [{
"type": "S",
"inputParams": { "id": "Sk" },
"persistentData": {
"basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
"sketch": {
"points": [
{ "id": 0, "x": 0.0, "y": 0.0 }, { "id": 1, "x": 30.0, "y": 0.0 },
{ "id": 2, "x": 10.0, "y": -5.0 }, { "id": 3, "x": 10.0, "y": 5.0 },
{ "id": 4, "x": 20.0, "y": -5.0 }, { "id": 5, "x": 20.0, "y": 5.0 }
],
"geometries": [
{ "id": 10, "type": "line", "points": [0, 1] },
{ "id": 11, "type": "line", "points": [2, 3] },
{ "id": 12, "type": "line", "points": [4, 5] }
],
"constraints": []
}
}
}]
})
.to_string()
}
fn trim_sketch_engine() -> EngineState {
let mut engine = EngineState::new();
engine.set_history_json(&trim_sketch_history()).unwrap();
engine.enter_sketch_mode("Sk").expect("enter");
engine.resize(800.0, 600.0);
engine.camera.eye = [15.0, 0.0, 50.0];
engine.camera.target = [15.0, 0.0, 0.0];
engine.camera.up = [0.0, 1.0, 0.0];
engine.camera.projection = crate::view::Projection::Orthographic { half_height: 30.0 };
engine
}
#[test]
fn trim_tool_click_splits_the_middle_span_and_undo_restores() {
let mut engine = trim_sketch_engine();
engine.sketch_set_tool(Some("trim"));
assert_eq!(engine.sketch_active_tool(), Some("trim"));
let geo_before = engine.sketch_edit_session().unwrap().doc.geometries.len();
engine.sketch_tool_place_uv(15.0, 0.0);
assert_eq!(engine.sketch_pending_len(), 0, "trim never buffers pending");
let s = engine.sketch_edit_session().unwrap();
assert!(
s.doc.geometry(&serde_json::json!(10)).is_none(),
"target line survived the trim"
);
assert_eq!(s.doc.geometries.len(), geo_before + 1, "2 crossers + 2 sub-segments");
assert!(
s.doc.points.iter().any(|p| (p.x - 10.0).abs() < 1e-6 && p.y.abs() < 1e-6)
&& s.doc.points.iter().any(|p| (p.x - 20.0).abs() < 1e-6 && p.y.abs() < 1e-6),
"cut points not at the intersections"
);
let doc = s.doc.clone();
assert!(
crate::sketch::SketchSession::new(doc, crate::sketch::PlaneFrame::xy()).is_ok(),
"post-trim doc did not solve"
);
assert!(engine.sketch_undo(), "undo");
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.geometries.len(), geo_before, "undo did not restore geometry count");
assert!(
s.doc.geometry(&serde_json::json!(10)).is_some(),
"undo did not restore the target line"
);
}
#[test]
fn trim_click_on_a_point_deletes_it_and_dependents() {
let mut engine = seed_sketch_engine();
engine.sketch_set_tool(Some("trim"));
let pts_before = engine.sketch_edit_session().unwrap().doc.points.len();
let geo_before = engine.sketch_edit_session().unwrap().doc.geometries.len();
engine.sketch_tool_place_uv(34.0, 6.0);
let s = engine.sketch_edit_session().unwrap();
assert!(
s.doc.point(&serde_json::json!(4)).is_none(),
"trim did not delete the clicked point"
);
assert!(
s.doc.geometry(&serde_json::json!(20)).is_none(),
"the circle referencing the deleted center should be gone"
);
assert_eq!(s.doc.geometries.len(), geo_before - 1, "one geometry removed");
assert!(
s.doc.points.len() < pts_before,
"the center + orphaned radius point removed"
);
assert!(engine.sketch_undo(), "undo");
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.points.len(), pts_before, "undo restored the points");
assert_eq!(s.doc.geometries.len(), geo_before, "undo restored the geometry");
}
#[test]
fn trim_tool_click_on_empty_space_is_a_noop_without_a_dead_undo_step() {
let mut engine = trim_sketch_engine();
engine.sketch_set_tool(Some("trim"));
let geo_before = engine.sketch_edit_session().unwrap().doc.geometries.len();
let (sx, sy, _) = engine.camera.project([15.0, 50.0, 0.0]);
assert!(!engine.sketch_trim_at(sx, sy), "empty-space trim should be a no-op");
assert_eq!(engine.sketch_edit_session().unwrap().doc.geometries.len(), geo_before);
assert!(!engine.sketch_can_undo(), "a no-op trim must not leave a dead undo step");
}
fn cube_plus_sketch_history() -> String {
serde_json::json!({
"features": [
{
"type": "P.CU",
"inputParams": {
"id": "Box",
"sizeX": 10.0, "sizeY": 10.0, "sizeZ": 10.0,
"transform": {
"position": [0.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
},
{
"type": "S",
"inputParams": { "id": "Sk" },
"persistentData": {
"basis": { "origin": [0, 0, 0], "x": [1, 0, 0], "y": [0, 1, 0], "z": [0, 0, 1] },
"sketch": { "points": [], "geometries": [], "constraints": [] }
}
}
]
})
.to_string()
}
fn pick_edges_engine() -> EngineState {
let mut engine = EngineState::new();
engine.set_history_json(&cube_plus_sketch_history()).unwrap();
engine.enter_sketch_mode("Sk").expect("enter");
engine.resize(800.0, 600.0);
engine.camera.eye = [5.0, 5.0, 60.0];
engine.camera.target = [5.0, 5.0, 5.0];
engine.camera.up = [0.0, 1.0, 0.0];
engine.camera.projection = crate::view::Projection::Orthographic { half_height: 10.0 };
engine
}
fn nondegenerate_cube_edges(engine: &EngineState) -> Vec<String> {
let entities: serde_json::Value =
serde_json::from_str(&engine.scene_entities_json()).unwrap();
let plane = engine.sketch_edit_session().unwrap().plane;
let mut out = Vec::new();
for e in entities[0]["edges"].as_array().unwrap() {
let name = e.as_str().unwrap();
if let Some(poly) = engine.scene.edge_polyline_world(name) {
let a = plane.to_uv(poly[0]);
let b = plane.to_uv(poly[poly.len() - 1]);
if ((a.0 - b.0).powi(2) + (a.1 - b.1).powi(2)).sqrt() > 1.0 {
out.push(name.to_string());
}
}
}
out
}
#[test]
fn edge_polyline_world_finds_a_named_scene_edge() {
let engine = pick_edges_engine();
let entities: serde_json::Value =
serde_json::from_str(&engine.scene_entities_json()).unwrap();
let name = entities[0]["edges"][0].as_str().unwrap();
let poly = engine
.scene
.edge_polyline_world(name)
.expect("known edge has a world polyline");
assert!(poly.len() >= 2, "edge polyline has >= 2 points");
assert_eq!(engine.scene.edge_solid_name(name), Some("Box"));
assert!(engine.scene.edge_polyline_world("nope").is_none());
}
#[test]
fn link_edge_materializes_a_grounded_construction_reference() {
let mut engine = pick_edges_engine();
let name = nondegenerate_cube_edges(&engine)
.into_iter()
.next()
.expect("a linkable cube edge");
assert!(engine.sketch_link_edge(&name), "a link was made");
assert_eq!(engine.sketch_external_ref_count(), 1);
let session = engine.sketch_edit_session().unwrap();
let ext: Vec<_> = session
.doc
.points
.iter()
.filter(|p| p.external_reference)
.collect();
assert_eq!(ext.len(), 2, "a straight edge links two endpoints");
for p in &ext {
assert!(p.fixed && p.construction && p.external_reference, "point flags: {p:?}");
}
let grounds = session
.doc
.constraints
.iter()
.filter(|c| c.ctype() == Some("⏚"))
.count();
assert_eq!(grounds, 2, "one ground per external endpoint");
assert_eq!(session.doc.geometries.len(), 1);
let g = &session.doc.geometries[0];
assert_eq!(g.geom_type, "line");
assert!(g.construction(), "reference geometry must be construction");
assert!(
crate::sketch::SketchSession::new(session.doc.clone(), session.plane).is_ok(),
"linked doc did not solve"
);
}
#[test]
fn link_edge_dedups_a_repick_and_adds_for_a_new_edge() {
let mut engine = pick_edges_engine();
let edges = nondegenerate_cube_edges(&engine);
let (a, b) = (edges[0].clone(), edges[1].clone());
assert!(engine.sketch_link_edge(&a));
assert_eq!(engine.sketch_external_ref_count(), 1);
assert_eq!(engine.sketch_edit_session().unwrap().doc.points.len(), 2);
assert_eq!(engine.sketch_edit_session().unwrap().doc.geometries.len(), 1);
assert!(!engine.sketch_link_edge(&a), "redundant re-link is a no-op");
assert_eq!(engine.sketch_external_ref_count(), 1);
assert_eq!(engine.sketch_edit_session().unwrap().doc.points.len(), 2);
assert_eq!(engine.sketch_edit_session().unwrap().doc.geometries.len(), 1);
assert!(engine.sketch_link_edge(&b));
assert_eq!(engine.sketch_external_ref_count(), 2);
assert_eq!(engine.sketch_edit_session().unwrap().doc.points.len(), 4);
assert_eq!(engine.sketch_edit_session().unwrap().doc.geometries.len(), 2);
}
#[test]
fn link_edge_undo_restores_the_pre_link_doc() {
let mut engine = pick_edges_engine();
let name = nondegenerate_cube_edges(&engine).remove(0);
assert!(engine.sketch_link_edge(&name));
assert_eq!(engine.sketch_edit_session().unwrap().doc.points.len(), 2);
assert!(engine.sketch_can_undo());
assert!(engine.sketch_undo());
assert_eq!(engine.sketch_edit_session().unwrap().doc.points.len(), 0);
assert_eq!(engine.sketch_edit_session().unwrap().doc.geometries.len(), 0);
assert_eq!(engine.sketch_external_ref_count(), 0);
assert!(engine.sketch_can_redo());
assert!(engine.sketch_redo());
assert_eq!(engine.sketch_edit_session().unwrap().doc.points.len(), 2);
assert_eq!(engine.sketch_external_ref_count(), 1);
}
#[test]
fn pick_edges_persists_and_reloads_external_refs() {
let mut engine = pick_edges_engine();
let name = nondegenerate_cube_edges(&engine).remove(0);
assert!(engine.sketch_link_edge(&name));
assert_eq!(engine.sketch_external_ref_count(), 1);
engine.exit_sketch_mode(true);
let index = engine.history.index_of("Sk").unwrap();
let pd = engine.history.feature_persistent_data(index).unwrap();
let refs = pd["externalRefs"].as_array().expect("externalRefs persisted");
assert_eq!(refs.len(), 1);
assert_eq!(refs[0]["edgeName"], serde_json::Value::String(name.clone()));
engine.enter_sketch_mode("Sk").expect("re-enter");
assert_eq!(engine.sketch_external_ref_count(), 1);
let ext = engine
.sketch_edit_session()
.unwrap()
.doc
.points
.iter()
.filter(|p| p.external_reference)
.count();
assert_eq!(ext, 2, "external-reference points reloaded");
}
#[test]
fn pick_edge_at_links_the_edge_under_the_cursor_and_misses_empty_space() {
let mut engine = pick_edges_engine();
engine.sketch_set_tool(Some("pickEdges"));
assert_eq!(engine.sketch_active_tool(), Some("pickEdges"));
assert!(!engine.sketch_pick_edge_at(2.0, 2.0), "empty-space pick is a no-op");
assert_eq!(engine.sketch_external_ref_count(), 0);
assert!(!engine.sketch_can_undo());
let mid = [5.0, 0.0, 10.0]; let (sx, sy, _) = engine.camera.project(mid);
assert!(engine.sketch_pick_edge_at(sx, sy), "edge under the cursor links");
assert_eq!(engine.sketch_external_ref_count(), 1);
}
#[test]
fn pick_edges_tool_routes_clicks_through_the_tool_state_machine() {
let mut engine = pick_edges_engine();
engine.sketch_set_tool(Some("pickEdges"));
let mid = [0.0, 5.0, 10.0]; let (sx, sy, _) = engine.camera.project(mid);
engine.sketch_tool_click_at(sx, sy);
assert_eq!(engine.sketch_external_ref_count(), 1, "click linked an edge");
assert_eq!(engine.sketch_pending_len(), 0, "pickEdges never buffers a pending point");
}
fn draw_circle_stroke(engine: &mut EngineState, cx: f64, cy: f64, r: f64, n: usize) -> bool {
engine.sketch_set_tool(Some("handdraw"));
let pt = |i: usize| {
let t = i as f64 / n as f64 * std::f64::consts::TAU;
(cx + r * t.cos(), cy + r * t.sin())
};
let (u0, v0) = pt(0);
engine.sketch_handdraw_begin_uv(u0, v0);
for i in 1..=n {
let (u, v) = pt(i);
engine.sketch_handdraw_move_uv(u, v);
}
engine.sketch_handdraw_end()
}
#[test]
fn handdraw_circle_stroke_creates_a_circle_and_two_points() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
assert!(draw_circle_stroke(&mut engine, 60.0, 40.0, 8.0, 48), "circle recognized");
let (p1, g1) = counts(&engine);
assert_eq!(p1, p0 + 2, "circle adds center + rim");
assert_eq!(g1, g0 + 1, "circle adds one geometry");
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.geometries.last().unwrap().geom_type, "circle");
assert_eq!(engine.sketch_handdraw_len(), 0);
}
#[test]
fn handdraw_straight_stroke_creates_a_line() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
engine.sketch_set_tool(Some("handdraw"));
engine.sketch_handdraw_begin_uv(55.0, 40.0);
for i in 1..=10 {
engine.sketch_handdraw_move_uv(55.0 + i as f64 * 2.0, 40.0);
}
assert!(engine.sketch_handdraw_end(), "line recognized");
let (p1, g1) = counts(&engine);
assert_eq!(p1, p0 + 2, "line adds two endpoints");
assert_eq!(g1, g0 + 1);
let s = engine.sketch_edit_session().unwrap();
assert_eq!(s.doc.geometries.last().unwrap().geom_type, "line");
}
#[test]
fn handdraw_too_short_stroke_creates_nothing_and_no_dead_undo() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
assert!(!engine.sketch_can_undo(), "fresh session has no undo");
engine.sketch_set_tool(Some("handdraw"));
engine.sketch_handdraw_begin_uv(50.0, 50.0);
assert!(!engine.sketch_handdraw_end(), "too-short stroke creates nothing");
assert_eq!(counts(&engine), (p0, g0), "doc unchanged");
assert!(!engine.sketch_can_undo(), "no dead undo step left behind");
assert_eq!(engine.sketch_handdraw_len(), 0);
}
#[test]
fn handdraw_undo_restores_the_pre_stroke_doc() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
assert!(draw_circle_stroke(&mut engine, 60.0, 40.0, 8.0, 48));
assert_ne!(counts(&engine), (p0, g0), "stroke added geometry");
assert!(engine.sketch_can_undo());
assert!(engine.sketch_undo(), "undo restores the pre-stroke doc");
assert_eq!(counts(&engine), (p0, g0), "undo reverted the handdraw shape");
}
#[test]
fn handdraw_stroke_starting_on_an_existing_point_reuses_its_id() {
let mut engine = seed_sketch_engine();
let (p0, _) = counts(&engine);
engine.sketch_set_tool(Some("handdraw"));
engine.sketch_handdraw_begin_uv(34.0, 6.0);
for i in 1..=8 {
engine.sketch_handdraw_move_uv(34.0 + i as f64 * 2.0, 6.0);
}
assert!(engine.sketch_handdraw_end());
let (p1, _) = counts(&engine);
assert_eq!(p1, p0 + 1, "the start snapped to point 4; only the end is fresh");
let s = engine.sketch_edit_session().unwrap();
let line = s.doc.geometries.last().unwrap();
assert_eq!(line.geom_type, "line");
assert_eq!(id_key(&line.points[0]), "4", "start reused existing point 4");
}
#[test]
fn handdraw_move_tracks_the_stroke_and_a_plain_place_is_a_no_op() {
let mut engine = seed_sketch_engine();
let (p0, g0) = counts(&engine);
engine.sketch_set_tool(Some("handdraw"));
engine.sketch_tool_place_uv(50.0, 50.0);
assert_eq!(counts(&engine), (p0, g0), "handdraw click places nothing");
assert!(!engine.sketch_can_undo(), "handdraw click records no undo");
engine.sketch_handdraw_begin_uv(50.0, 50.0);
assert_eq!(engine.sketch_handdraw_len(), 1);
engine.sketch_handdraw_move_uv(53.0, 50.0);
engine.sketch_handdraw_move_uv(56.0, 50.0);
assert_eq!(engine.sketch_handdraw_len(), 3, "spaced samples accumulate");
engine.sketch_handdraw_move_uv(56.05, 50.0);
assert_eq!(engine.sketch_handdraw_len(), 3, "throttled sample skipped");
}