diff --git a/crates/cord-gui/src/app.rs b/crates/cord-gui/src/app.rs index 1471312..f05ea63 100644 --- a/crates/cord-gui/src/app.rs +++ b/crates/cord-gui/src/app.rs @@ -466,6 +466,8 @@ impl App { Err(e) => { self.info = None; self.error = Some(e); + self.viewport.set_graph(&default_sphere_graph()); + self.viewport.set_bounds(2.0); } } return; @@ -545,6 +547,8 @@ impl App { self.scene_objects.clear(); self.needs_cast = false; self.needs_plot = false; + self.viewport.set_graph(&default_sphere_graph()); + self.viewport.set_bounds(2.0); } } } @@ -598,7 +602,7 @@ impl App { let config = DecompileConfig::default(); let source = match decompile(&mesh, &config) { - Ok(result) => sdf_to_source(&result.sdf, "imported"), + Ok(result) => cord_sdf::sdf_to_cordial(&result.sdf), Err(e) => { self.status = Some(format!("decompose error: {e}")); return; @@ -638,6 +642,7 @@ impl App { .to_lowercase(); let is_mesh = matches!(ext.as_str(), "obj" | "stl" | "3mf"); + let mut fallback_note: Option<&str> = None; let result = match ext.as_str() { "zcd" => load_zcd(path), @@ -646,7 +651,15 @@ impl App { Err("binary format (.cord) — no source to edit".into()) } "obj" | "stl" | "3mf" => { - import_mesh(path) + match import_mesh(path) { + Ok(imp) => { + if imp.is_fallback { + fallback_note = Some(" (bounding box — full decompose failed)"); + } + Ok(imp.source) + } + Err(e) => Err(e), + } } "step" | "stp" => { Err(format!("import for .{ext} not yet implemented")) @@ -666,7 +679,8 @@ impl App { self.reparse(); self.update_markdown(); self.push_recent(path); - self.status = Some(format!("opened: {}", path.display())); + let note = fallback_note.unwrap_or(""); + self.status = Some(format!("opened: {}{note}", path.display())); } Err(e) => { self.status = Some(format!("error: {e}")); @@ -1649,6 +1663,20 @@ fn estimate_bounds(graph: &cord_trig::TrigGraph) -> f64 { max_r.clamp(0.5, 10000.0) } +fn default_sphere_graph() -> cord_trig::TrigGraph { + use cord_trig::ir::{TrigGraph, TrigOp}; + let mut g = TrigGraph::new(); + let x = g.push(TrigOp::InputX); + let y = g.push(TrigOp::InputY); + let z = g.push(TrigOp::InputZ); + let xy = g.push(TrigOp::Hypot(x, y)); + let mag = g.push(TrigOp::Hypot(xy, z)); + let r = g.push(TrigOp::Const(2.0)); + let out = g.push(TrigOp::Sub(mag, r)); + g.set_output(out); + g +} + fn parse_scad(src: &str) -> Result<(cord_trig::TrigGraph, f64), String> { use cord_parse::lexer::Lexer; use cord_parse::parser::Parser; @@ -1683,15 +1711,26 @@ fn load_zcd(path: &std::path::Path) -> Result { Err("no readable layers in .zcd".into()) } -fn import_mesh(path: &std::path::Path) -> Result { +struct MeshImport { + source: String, + is_fallback: bool, +} + +fn import_mesh(path: &std::path::Path) -> Result { use cord_decompile::mesh::TriangleMesh; use cord_decompile::{decompile, DecompileConfig}; let mesh = TriangleMesh::load(path).map_err(|e| e.to_string())?; let config = DecompileConfig::default(); match decompile(&mesh, &config) { - Ok(result) => Ok(sdf_to_source(&result.sdf, "imported")), - Err(_) => Ok(mesh_bounding_source(&mesh)), + Ok(result) => Ok(MeshImport { + source: cord_sdf::sdf_to_cordial(&result.sdf), + is_fallback: false, + }), + Err(_) => Ok(MeshImport { + source: mesh_bounding_source(&mesh), + is_fallback: true, + }), } } @@ -1710,98 +1749,6 @@ fn mesh_bounding_source(mesh: &cord_decompile::mesh::TriangleMesh) -> String { ) } -fn sdf_to_source(node: &cord_sdf::SdfNode, prefix: &str) -> String { - let mut out = String::new(); - let mut counter = 0u32; - let final_expr = sdf_node_emit(node, prefix, &mut counter, &mut out); - use std::fmt::Write; - let _ = writeln!(out, "let result: Obj = {final_expr}"); - let _ = writeln!(out, "cast(result)"); - out -} - -fn sdf_node_emit( - node: &cord_sdf::SdfNode, - prefix: &str, - counter: &mut u32, - out: &mut String, -) -> String { - use cord_sdf::SdfNode; - use std::fmt::Write; - - match node { - SdfNode::Sphere { radius } => format!("sphere({radius:.4})"), - SdfNode::Box { half_extents: h } => format!("box({:.4}, {:.4}, {:.4})", h[0], h[1], h[2]), - SdfNode::Cylinder { radius, height } => format!("cylinder({radius:.4}, {:.4})", height / 2.0), - - SdfNode::Translate { offset, child } => { - let inner = sdf_node_emit(child, prefix, counter, out); - format!("translate({inner}, {:.4}, {:.4}, {:.4})", offset[0], offset[1], offset[2]) - } - SdfNode::Rotate { axis: _, angle_deg, child } => { - let inner = sdf_node_emit(child, prefix, counter, out); - let rad = angle_deg * std::f64::consts::PI / 180.0; - format!("rotate_z({inner}, {rad:.6})") - } - SdfNode::Scale { factor, child } => { - let inner = sdf_node_emit(child, prefix, counter, out); - let s = factor[0].max(factor[1]).max(factor[2]); - format!("scale({inner}, {s:.4})") - } - - SdfNode::Union(children) => { - let mut names = Vec::new(); - for child in children { - let name = format!("{prefix}_{counter}"); - *counter += 1; - let expr = sdf_node_emit(child, prefix, counter, out); - let _ = writeln!(out, "let {name}: Obj = {expr}"); - names.push(name); - } - if names.len() == 1 { - return names[0].clone(); - } - let mut result = format!("union({}, {})", names[0], names[1]); - for n in &names[2..] { - result = format!("union({result}, {n})"); - } - result - } - SdfNode::Intersection(children) => { - let exprs: Vec = children.iter() - .map(|c| sdf_node_emit(c, prefix, counter, out)) - .collect(); - let mut result = format!("intersect({}, {})", exprs[0], exprs[1]); - for e in &exprs[2..] { - result = format!("intersect({result}, {e})"); - } - result - } - SdfNode::Difference { base, subtract } => { - let base_expr = sdf_node_emit(base, prefix, counter, out); - let sub_exprs: Vec = subtract.iter() - .map(|c| sdf_node_emit(c, prefix, counter, out)) - .collect(); - let mut result = base_expr; - for s in &sub_exprs { - result = format!("diff({result}, {s})"); - } - result - } - SdfNode::SmoothUnion { children, .. } => { - let exprs: Vec = children.iter() - .map(|c| sdf_node_emit(c, prefix, counter, out)) - .collect(); - if exprs.len() == 1 { return exprs[0].clone(); } - let mut result = format!("union({}, {})", exprs[0], exprs[1]); - for e in &exprs[2..] { - result = format!("union({result}, {e})"); - } - result - } - } -} - fn find_object_line(lines: &[&str], name: &str) -> Option { for (i, line) in lines.iter().enumerate() { let t = line.trim(); @@ -2280,3 +2227,72 @@ fn setup_native_menu() { std::mem::forget(menu); } + +#[cfg(test)] +mod tests { + use cord_expr::parse_expr_scene; + use cord_sdf::SdfNode; + use cord_trig::eval::evaluate; + + #[test] + fn cordial_roundtrip_sphere() { + let node = SdfNode::Sphere { radius: 5.0 }; + let src = cord_sdf::sdf_to_cordial(&node); + let scene = parse_expr_scene(&src).unwrap_or_else(|e| panic!("{e}\n{src}")); + let graph = cord_expr::resolve_scene(scene); + assert!((evaluate(&graph, 5.0, 0.0, 0.0)).abs() < 1e-4); + assert!(evaluate(&graph, 0.0, 0.0, 0.0) < 0.0); + } + + #[test] + fn cordial_roundtrip_translated_box() { + let node = SdfNode::Translate { + offset: [3.0, 0.0, 0.0], + child: Box::new(SdfNode::Box { half_extents: [2.0, 2.0, 2.0] }), + }; + let src = cord_sdf::sdf_to_cordial(&node); + let scene = parse_expr_scene(&src).unwrap_or_else(|e| panic!("{e}\n{src}")); + let graph = cord_expr::resolve_scene(scene); + assert!(evaluate(&graph, 3.0, 0.0, 0.0) < 0.0); + } + + #[test] + fn cordial_roundtrip_rotated_cylinder() { + let node = SdfNode::Rotate { + axis: [1.0, 0.0, 0.0], + angle_deg: 90.0, + child: Box::new(SdfNode::Cylinder { radius: 2.0, height: 10.0 }), + }; + let src = cord_sdf::sdf_to_cordial(&node); + assert!(src.contains("rotate_x("), "axis=X should emit rotate_x, got:\n{src}"); + let scene = parse_expr_scene(&src).unwrap_or_else(|e| panic!("{e}\n{src}")); + let _graph = cord_expr::resolve_scene(scene); + } + + #[test] + fn cordial_roundtrip_union() { + let node = SdfNode::Union(vec![ + SdfNode::Sphere { radius: 1.0 }, + SdfNode::Translate { + offset: [5.0, 0.0, 0.0], + child: Box::new(SdfNode::Sphere { radius: 1.0 }), + }, + ]); + let src = cord_sdf::sdf_to_cordial(&node); + let scene = parse_expr_scene(&src).unwrap_or_else(|e| panic!("{e}\n{src}")); + let graph = cord_expr::resolve_scene(scene); + assert!(evaluate(&graph, 0.0, 0.0, 0.0) < 0.0); + assert!(evaluate(&graph, 5.0, 0.0, 0.0) < 0.0); + assert!(evaluate(&graph, 2.5, 0.0, 0.0) > 0.0); + } + + #[test] + fn mesh_bounding_source_parses() { + let src = "// bounding box approximation\n\ + let result: Obj = translate(box(5.0000, 3.0000, 2.0000), 1.0000, 2.0000, 0.0000)\n\ + cast(result)"; + let scene = parse_expr_scene(src).unwrap_or_else(|e| panic!("{e}\n{src}")); + let graph = cord_expr::resolve_scene(scene); + assert!(evaluate(&graph, 1.0, 2.0, 0.0) < 0.0); + } +}