use super::*;
fn cube_request(name: &str, size: f64) -> String {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [{
"type": "P.CU",
"inputParams": {
"id": name,
"sizeX": size, "sizeY": size, "sizeZ": size,
"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": {}
}]
})
.to_string()
}
fn boolean_history() -> String {
serde_json::json!({
"expressions": "",
"configurator": {},
"features": [
{
"type": "P.CU",
"inputParams": {
"id": "Box",
"sizeX": 20.0, "sizeY": 20.0, "sizeZ": 20.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": "P.CY",
"inputParams": {
"id": "Pin",
"radius": 6.0, "height": 30.0,
"transform": {
"position": [10.0, -5.0, 10.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
},
{
"type": "B",
"inputParams": {
"id": "Cut",
"targetSolid": "Box",
"boolean": { "operation": "SUBTRACT", "targets": ["Pin"] }
},
"persistentData": {}
}
]
})
.to_string()
}
#[test]
fn ref_select_rolls_to_before_and_highlights_seed() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
assert_eq!(engine.scene.solids().len(), 1);
engine.begin_ref_select(
"Cut",
vec!["targetSolid".into()],
"Target solid".into(),
vec!["SOLID".into()],
false,
vec!["Box".into()],
);
assert!(engine.ref_select_active());
assert_eq!(engine.scene.solids().len(), 2);
assert!(engine.emphasis.selected_solids.contains("Box"));
assert_eq!(engine.ref_select_names(), vec!["Box".to_string()]);
}
#[test]
fn ref_select_edge_pick_under_a_face_edge_filter_highlights_the_edge() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
engine.begin_ref_select(
"Cut",
vec!["edgeRef".into()],
"Edges".into(),
vec!["FACE".into(), "EDGE".into()],
true,
vec!["Box_PY|Box_PZ[0]".into()],
);
assert!(
engine.emphasis.selected_edges.contains("Box_PY|Box_PZ[0]"),
"an edge pick under a FACE-first filter must highlight as an edge"
);
assert!(engine.emphasis.selected_faces.contains("Box_PY|Box_PZ[0]"));
}
#[test]
fn ref_select_finish_writes_single_ref_and_restores() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
engine.begin_ref_select(
"Cut",
vec!["targetSolid".into()],
"Target solid".into(),
vec!["SOLID".into()],
false,
vec![],
);
engine.ref_select.as_mut().unwrap().names = vec!["Box".into()];
engine.finish_ref_select();
assert!(!engine.ref_select_active());
assert!(engine.emphasis.is_empty(), "highlight cleared on finish");
assert_eq!(engine.scene.solids().len(), 1);
let idx = engine.history.index_of("Cut").unwrap();
let params = engine.history.feature_params(idx).unwrap();
assert_eq!(params["targetSolid"], "Box");
}
#[test]
fn ref_select_finish_writes_multiple_nested_ref() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
engine.begin_ref_select(
"Cut",
vec!["boolean".into(), "targets".into()],
"Tool solids".into(),
vec!["SOLID".into()],
true,
vec![],
);
let state = engine.ref_select.as_mut().unwrap();
state.names = vec!["Pin".into()];
if !state.names.iter().any(|n| n == "Pin") {
state.names.push("Pin".into());
}
engine.finish_ref_select();
let idx = engine.history.index_of("Cut").unwrap();
let params = engine.history.feature_params(idx).unwrap();
assert_eq!(params["boolean"]["targets"], serde_json::json!(["Pin"]));
}
#[test]
fn ref_select_remove_and_cancel_leave_params_untouched() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
let idx = engine.history.index_of("Cut").unwrap();
let before = engine.history.feature_params(idx).unwrap();
engine.begin_ref_select(
"Cut",
vec!["boolean".into(), "targets".into()],
"Tool solids".into(),
vec!["SOLID".into()],
true,
vec!["Pin".into(), "Box".into()],
);
engine.ref_select_remove(0); assert_eq!(engine.ref_select_names(), vec!["Box".to_string()]);
engine.cancel_ref_select();
assert!(!engine.ref_select_active());
assert!(engine.emphasis.is_empty());
assert_eq!(engine.history.feature_params(idx).unwrap(), before);
assert_eq!(engine.scene.solids().len(), 1);
}
#[test]
fn ref_select_solid_field_constrains_filter_then_finish_restores() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
assert_eq!(engine.selection_filter(), SelectionFilter::default());
engine.begin_ref_select(
"Cut",
vec!["targetSolid".into()],
"Target solid".into(),
vec!["SOLID".into()],
false,
vec![],
);
let f = engine.selection_filter();
assert!(f.solid && !f.face && !f.edge && !f.vertex, "solid-only: {f:?}");
assert_eq!(
engine.selection_filter_json(),
r#"{"SOLID":true,"FACE":false,"EDGE":false,"VERTEX":false}"#
);
engine.ref_select.as_mut().unwrap().names = vec!["Box".into()];
engine.finish_ref_select();
assert_eq!(engine.selection_filter(), SelectionFilter::default());
}
#[test]
fn ref_select_face_edge_field_enables_exactly_those_then_cancel_restores() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
engine.begin_ref_select(
"Cut",
vec!["faceRef".into()],
"Faces / edges".into(),
vec!["FACE".into(), "EDGE".into()],
true,
vec![],
);
let f = engine.selection_filter();
assert!(
!f.solid && f.face && f.edge && !f.vertex,
"face+edge only: {f:?}"
);
engine.cancel_ref_select();
assert_eq!(engine.selection_filter(), SelectionFilter::default());
}
#[test]
fn ref_select_plane_face_field_maps_to_face_only() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
engine.begin_ref_select(
"Cut",
vec!["plane".into()],
"Sketch plane".into(),
vec!["PLANE".into(), "FACE".into()],
false,
vec![],
);
let f = engine.selection_filter();
assert!(!f.solid && f.face && !f.edge && !f.vertex, "face-only: {f:?}");
engine.cancel_ref_select();
assert_eq!(engine.selection_filter(), SelectionFilter::default());
}
#[test]
fn ref_select_datum_only_field_falls_back_to_all_enabled() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
engine.begin_ref_select(
"Cut",
vec!["datum".into()],
"Datum".into(),
vec!["DATUM".into()],
false,
vec![],
);
assert_eq!(
engine.selection_filter(),
SelectionFilter::default(),
"construction-only field → all-enabled fallback"
);
engine.cancel_ref_select();
assert_eq!(engine.selection_filter(), SelectionFilter::default());
}
#[test]
fn ref_select_empty_field_filter_falls_back_to_all_enabled() {
let mut engine = EngineState::new();
engine.set_history_json(&boolean_history()).unwrap();
engine.begin_ref_select(
"Cut",
vec!["anything".into()],
"Anything".into(),
vec![],
false,
vec![],
);
assert_eq!(engine.selection_filter(), SelectionFilter::default());
}
#[test]
fn from_ref_filter_maps_kinds_and_falls_back() {
let m = |v: &[&str]| {
SelectionFilter::from_ref_filter(&v.iter().map(|s| s.to_string()).collect::<Vec<_>>())
};
assert_eq!(
m(&["SOLID"]),
SelectionFilter { solid: true, face: false, edge: false, vertex: false }
);
assert_eq!(
m(&["FACE", "EDGE"]),
SelectionFilter { solid: false, face: true, edge: true, vertex: false }
);
assert_eq!(
m(&["PLANE", "FACE"]),
SelectionFilter { solid: false, face: true, edge: false, vertex: false }
);
assert_eq!(m(&["DATUM"]), SelectionFilter::default());
assert_eq!(m(&["PLANE"]), SelectionFilter::default());
assert_eq!(m(&[]), SelectionFilter::default());
}
#[test]
fn run_history_feeds_scene_and_lists_it() {
let mut engine = EngineState::new();
let report = engine
.run_history_json(&cube_request("EngineCube", 10.0), None)
.unwrap();
assert!(report.contains("featureErrors"));
assert_eq!(engine.scene.solids().len(), 1);
let listing: serde_json::Value =
serde_json::from_str(&engine.scene_listing_json()).unwrap();
assert_eq!(listing[0]["name"], "EngineCube");
assert_eq!(listing[0]["visible"], true);
assert_eq!(listing[0]["faces"], 6);
}
#[test]
fn pick_and_hover_return_kernel_names() {
let mut engine = EngineState::new();
engine
.run_history_json(&cube_request("PickCube", 10.0), None)
.unwrap();
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 };
let hits: serde_json::Value = serde_json::from_str(&engine.pick_json(400.0, 300.0)).unwrap();
assert!(hits.as_array().unwrap().len() >= 2);
assert_eq!(hits[0]["kind"], "FACE");
assert!(hits[0]["name"].as_str().unwrap().len() > 0);
assert_eq!(hits.as_array().unwrap().last().unwrap()["kind"], "SOLID");
let hover: serde_json::Value = serde_json::from_str(&engine.hover_json(400.0, 300.0)).unwrap();
assert_eq!(hover["kind"], "FACE");
assert_eq!(engine.hover_json(5.0, 5.0), "null");
}
#[test]
fn world_to_screen_roundtrips_center() {
let mut engine = EngineState::new();
engine.resize(640.0, 480.0);
engine.camera.eye = [0.0, 0.0, 20.0];
engine.camera.target = [0.0, 0.0, 0.0];
engine.camera.projection = crate::view::Projection::Orthographic { half_height: 10.0 };
let out: Vec<[f64; 4]> =
serde_json::from_str(&engine.world_to_screen_json("[[0,0,0]]").unwrap()).unwrap();
assert!((out[0][0] - 320.0).abs() < 1e-9);
assert!((out[0][1] - 240.0).abs() < 1e-9);
assert_eq!(out[0][3], 1.0);
}
#[test]
fn camera_commands_set_dirty_and_persist() {
let mut engine = EngineState::new();
engine
.run_history_json(&cube_request("C", 4.0), None)
.unwrap();
engine.dirty = false;
engine.zoom_to_fit();
assert!(engine.dirty);
let saved = engine.camera_state_json();
assert_eq!(engine.toggle_projection(), "perspective");
engine.apply_camera_state_json(&saved).unwrap();
assert!(matches!(
engine.camera.projection,
crate::view::Projection::Orthographic { .. }
));
}
#[test]
fn viewcube_arrows_apply_relative_rotation() {
use brep_gizmos::view_cube::ViewCube;
let size = ViewCube::new().size() as f64;
let loc = |sx: f64, sy: f64| ((0.5 + 0.5 * sx) * size, (0.5 - 0.5 * sy) * size);
let rc = (0.99 + 2.0 * 0.80) / 3.0;
let base = |engine: &mut EngineState| {
engine.resize(800.0, 600.0);
engine.set_viewcube_enabled(true);
engine.camera.eye = [0.0, 20.0, 0.0];
engine.camera.target = [0.0, 0.0, 0.0];
engine.camera.up = [0.0, 0.0, 1.0];
};
let mut e = EngineState::new();
base(&mut e);
let (lx, ly) = loc(rc, 0.0);
assert!(e.viewcube_click(lx, ly), "right arrow hit");
assert!(e.camera.eye[0].abs() > 5.0, "orbited into X: {:?}", e.camera.eye);
assert!(e.camera.eye[1].abs() < 1e-6, "left +Y: {:?}", e.camera.eye);
assert!(e.camera.eye[2].abs() < 1e-6, "kept height: {:?}", e.camera.eye);
assert_eq!(e.camera.up, [0.0, 0.0, 1.0], "orbit keeps up");
let mut e2 = EngineState::new();
base(&mut e2);
let (lx, ly) = loc(-rc, 0.0);
assert!(e2.viewcube_click(lx, ly), "left arrow hit");
assert!(
e.camera.eye[0].signum() != e2.camera.eye[0].signum(),
"left/right orbit opposite azimuth: {:?} vs {:?}",
e.camera.eye,
e2.camera.eye
);
let mut eu = EngineState::new();
base(&mut eu);
let (lx, ly) = loc(0.0, rc);
assert!(eu.viewcube_click(lx, ly), "up arrow hit");
assert!(eu.camera.eye[2] > 5.0, "elevated to +Z: {:?}", eu.camera.eye);
assert_ne!(eu.camera.up, [0.0, 0.0, 1.0], "up-arrow tilts the up vector");
let mut er = EngineState::new();
base(&mut er);
let eye_before = er.camera.eye;
let (lx, ly) = loc(-0.66, 0.66 + 0.19);
assert!(er.viewcube_click(lx, ly), "roll-ccw arrow hit");
assert_eq!(er.camera.eye, eye_before, "roll leaves the eye put");
assert_ne!(er.camera.up, [0.0, 0.0, 1.0], "roll spins the up vector");
}
#[test]
fn settings_and_visibility_toggles() {
let mut engine = EngineState::new();
engine
.run_history_json(&cube_request("V", 4.0), None)
.unwrap();
let gen0 = engine.settings_generation;
engine.apply_settings_json(r##"{"flatShading": true, "faceColor": "#ff0000"}"##).unwrap();
assert!(engine.settings.flat_shading);
assert_ne!(engine.settings_generation, gen0);
assert!(engine.set_visible("V", false));
assert!(!engine.scene.solid("V").unwrap().visible);
assert!(!engine.set_visible("Nope", false));
}
#[test]
fn lod_factor_change_reruns_once_repeats_and_non_lod_do_not() {
let mut empty = EngineState::new();
let boot_gen = empty.run_generation;
empty.apply_settings_json(r##"{"renderQuality": "Draft"}"##).unwrap();
assert_eq!(empty.settings.lod_factor, 4.0);
assert_eq!(
empty.run_generation, boot_gen,
"a quality change with no document loaded must not re-run"
);
let mut engine = EngineState::new();
engine.set_history_json(&cube_request("Ball", 6.0)).unwrap();
let gen0 = engine.run_generation;
engine.apply_settings_json(r##"{"renderQuality": "Low"}"##).unwrap();
assert_eq!(engine.settings.lod_factor, 2.0);
assert_eq!(engine.run_generation, gen0 + 1, "a quality change must submit one re-run");
let gen1 = engine.run_generation;
engine.apply_settings_json(r##"{"renderQuality": "Low"}"##).unwrap();
assert_eq!(engine.run_generation, gen1, "an unchanged quality must not re-run");
engine.apply_settings_json(r##"{"flatShading": true}"##).unwrap();
assert_eq!(engine.run_generation, gen1, "a non-lod setting change must not re-run");
}
#[test]
fn full_buffer_apply_keeps_wireframe_and_still_changes_lod() {
let mut engine = EngineState::new();
engine.set_history_json(&cube_request("Ball", 6.0)).unwrap();
engine.apply_settings_json(r##"{"wireframe": true}"##).unwrap(); assert!(engine.settings.wireframe);
let mut buf: serde_json::Value =
serde_json::from_str(&engine.settings_json()).unwrap();
buf["renderQuality"] = serde_json::Value::String("Draft".into());
let gen0 = engine.run_generation;
engine.apply_settings_json(&buf.to_string()).unwrap();
assert!(engine.settings.wireframe, "full-buffer apply must NOT reset wireframe");
assert_eq!(engine.settings.lod_factor, 4.0, "Render Quality must still change the lod");
assert_eq!(engine.run_generation, gen0 + 1, "the lod change must re-run exactly once");
}
#[test]
fn projection_round_trips_through_the_settings_apply_path() {
let ortho =
|e: &EngineState| matches!(e.camera.projection, crate::view::Projection::Orthographic { .. });
let reported = |e: &EngineState| {
serde_json::from_str::<serde_json::Value>(&e.settings_json()).unwrap()["orthographic"]
.as_bool()
.unwrap()
};
let mut engine = EngineState::new();
let start = ortho(&engine);
assert_eq!(reported(&engine), start, "settings_json must report the LIVE projection");
engine
.apply_settings_json(&format!("{{\"orthographic\": {}}}", !start))
.unwrap();
assert_eq!(ortho(&engine), !start, "apply must drive the camera");
assert_eq!(reported(&engine), !start, "settings_json follows the camera");
engine
.apply_settings_json(&format!("{{\"orthographic\": {start}}}"))
.unwrap();
assert_eq!(ortho(&engine), start, "toggling back restores the projection");
engine.apply_settings_json(r##"{"wireframe": true}"##).unwrap();
assert_eq!(ortho(&engine), start, "an apply without `orthographic` leaves projection alone");
}
#[test]
fn saved_orthographic_setting_restores_camera_on_boot() {
let ortho =
|e: &EngineState| matches!(e.camera.projection, crate::view::Projection::Orthographic { .. });
let mut a = EngineState::new();
a.apply_settings_json(r##"{"orthographic": true}"##).unwrap();
assert!(ortho(&a), "saved orthographic=true restores orthographic on boot");
if let crate::view::Projection::Orthographic { half_height } = a.camera.projection {
assert!(
half_height.is_finite() && half_height > 0.0,
"boot ortho half_height must be sane: {half_height}"
);
}
let mut b = EngineState::new();
b.apply_settings_json(r##"{"orthographic": false}"##).unwrap();
assert!(!ortho(&b), "saved orthographic=false restores perspective on boot");
}
#[test]
fn color_overrides_apply_from_history_feed() {
let mut engine = EngineState::new();
engine
.run_history_json(&cube_request("Tint", 4.0), Some(r##"{"Tint": "#00ff00"}"##))
.unwrap();
assert_eq!(
engine.scene.solid("Tint").unwrap().color_override,
Some([0.0, 1.0, 0.0])
);
}
#[test]
fn live_color_override_sets_and_clears() {
let mut engine = EngineState::new();
engine
.run_history_json(&cube_request("Live", 4.0), None)
.unwrap();
assert_eq!(engine.scene.solid("Live").unwrap().color_override, None);
engine.dirty = false;
assert!(engine.set_color_override("Live", Some("#123456")));
assert!(engine.dirty);
assert_eq!(
engine.scene.solid("Live").unwrap().color_override,
crate::style::parse_css_hex("#123456")
);
assert!(engine.set_color_override("Live", None));
assert_eq!(engine.scene.solid("Live").unwrap().color_override, None);
engine.set_color_override("Live", Some("#abcdef"));
assert!(engine.set_color_override("Live", Some("")));
assert_eq!(engine.scene.solid("Live").unwrap().color_override, None);
assert!(!engine.set_color_override("Nope", Some("#ffffff")));
}
#[test]
fn undo_redo_reverts_and_reapplies_geometry() {
let mut engine = EngineState::new();
engine.set_history_json(&cube_request("U", 10.0)).unwrap();
assert!(!engine.can_undo());
let base_x = engine.scene.solid("U").unwrap().bbox.max[0];
let mut params: serde_json::Value =
serde_json::from_str(&engine.feature_params_json(0)).unwrap();
params["sizeX"] = serde_json::json!(40.0);
engine
.update_feature_params("U", ¶ms.to_string())
.unwrap();
let grown_x = engine.scene.solid("U").unwrap().bbox.max[0];
assert!(grown_x > base_x, "edit grew the cube: {grown_x} > {base_x}");
assert!(engine.can_undo());
engine.undo();
let reverted_x = engine.scene.solid("U").unwrap().bbox.max[0];
assert!(
(reverted_x - base_x).abs() < 1e-6,
"undo reverted geometry: {reverted_x} == {base_x}"
);
assert!(engine.can_redo());
engine.redo();
let regrown_x = engine.scene.solid("U").unwrap().bbox.max[0];
assert!(
(regrown_x - grown_x).abs() < 1e-6,
"redo re-applied geometry: {regrown_x} == {grown_x}"
);
engine
.add_feature(&cube_feature_json("V", 6.0))
.unwrap();
assert_eq!(engine.scene.solids().len(), 2);
engine.undo();
assert_eq!(engine.scene.solids().len(), 1);
}
fn cube_feature_json(name: &str, size: f64) -> String {
serde_json::json!({
"type": "P.CU",
"inputParams": {
"id": name,
"sizeX": size, "sizeY": size, "sizeZ": size,
"transform": {
"position": [30.0, 0.0, 0.0],
"rotationEuler": [0.0, 0.0, 0.0],
"scale": [1.0, 1.0, 1.0]
},
"boolean": { "targets": [], "operation": "NONE" }
},
"persistentData": {}
})
.to_string()
}