use iced::widget::{ button, checkbox, column, container, markdown, mouse_area, row, scrollable, stack, text, text_editor, tooltip, rule, Shader, Space, }; use iced::{Background, Border, Color, Element, Fill, Length, Padding, Shadow, Subscription}; use iced::{mouse, keyboard, window, Point, Vector}; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; use cord_expr::{classify, classify_from, expr_to_sdf, parse_expr, parse_expr_scene, ExprInfo}; use crate::highlight::{CordHighlighter, CordHighlighterSettings, format_token}; use crate::viewport::SdfViewport; const UNDO_LIMIT: usize = 200; const MAX_RECENTS: usize = 10; pub struct App { source: text_editor::Content, mode: InputMode, info: Option, error: Option, viewport: SdfViewport, mouse_pos: Point, status: Option, md_items: Vec, undo_stack: Vec, redo_stack: Vec, current_path: Option, dirty: bool, recents: Vec, scene_objects: Vec, selected_object: Option, needs_cast: bool, needs_plot: bool, context_menu_pos: Option, menu_ready: bool, cursor_line: usize, line_eval_text: Option, mesh_path: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum InputMode { Expr, Scad } #[derive(Debug, Clone)] pub enum Message { EditorAction(text_editor::Action), New, Open, OpenPath(PathBuf), Save, SaveAs, SaveSources, ExportSingle, ExportIndividual, ExportMesh(MeshFormat), Undo, Redo, CopyText, CutText, PasteText, SelectAll, MousePress, MouseRelease, MouseMove(Point), MouseScroll(f32), RightClick, ToggleShadows(bool), ToggleAO(bool), ToggleGround(bool), MarkdownUrl(markdown::Uri), FileDrop(PathBuf), Transform(TransformAction), ResetView, SelectObject(String), DismissOverlay, RenderObjects, RenderPlots, RenderAll, DecomposeMesh, Tick, } #[derive(Debug, Clone, Copy)] pub enum MeshFormat { Stl, ThreeMf, Step, Scad } #[derive(Debug, Clone)] pub enum TransformAction { Translate(f32, f32, f32), RotateX, RotateY, RotateZ, ScaleUp, ScaleDown, MirrorX, MirrorY, MirrorZ, } fn recents_path() -> PathBuf { let mut p = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); p.push(".cord"); p.push("recents.json"); p } fn load_recents() -> Vec { let p = recents_path(); std::fs::read_to_string(&p) .ok() .and_then(|s| serde_json::from_str::>(&s).ok()) .map(|v| v.into_iter().map(PathBuf::from).filter(|p| p.exists()).collect()) .unwrap_or_default() } fn save_recents(recents: &[PathBuf]) { let p = recents_path(); if let Some(parent) = p.parent() { let _ = std::fs::create_dir_all(parent); } let strs: Vec<&str> = recents.iter().filter_map(|p| p.to_str()).collect(); if let Ok(json) = serde_json::to_string_pretty(&strs) { let _ = std::fs::write(&p, json); } } impl App { pub fn new() -> Self { let initial = "let s = sphere(3)\ncast()"; let mut app = Self { source: text_editor::Content::with_text(initial), mode: InputMode::Expr, info: None, error: None, viewport: SdfViewport::new(), mouse_pos: Point::ORIGIN, status: None, md_items: Vec::new(), undo_stack: vec![initial.to_string()], redo_stack: Vec::new(), current_path: None, dirty: false, recents: load_recents(), scene_objects: Vec::new(), selected_object: None, needs_cast: false, needs_plot: false, context_menu_pos: None, menu_ready: false, cursor_line: 0, line_eval_text: None, mesh_path: None, }; app.reparse(); if let Some(path) = std::env::args_os().nth(1) { let p = std::path::PathBuf::from(path); if p.exists() { app.open_path(&p); } } app } pub fn title(&self) -> String { let name = self.current_path.as_ref() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .unwrap_or("untitled"); let dirty = if self.dirty { " *" } else { "" }; format!("{name}{dirty} — Cord") } pub fn update(&mut self, message: Message) { match &message { Message::MouseMove(_) | Message::MouseScroll(_) | Message::Tick => {} _ => { self.context_menu_pos = None; } } match message { Message::EditorAction(action) => { let is_edit = action.is_edit(); self.source.perform(action); let new_line = self.source.cursor().position.line; if new_line != self.cursor_line { self.cursor_line = new_line; self.line_eval_text = self.eval_line_at(new_line); } if is_edit { self.dirty = true; self.status = None; let snap = self.source.text(); if self.undo_stack.last().map_or(true, |s| *s != snap) { self.undo_stack.push(snap); if self.undo_stack.len() > UNDO_LIMIT { self.undo_stack.remove(0); } self.redo_stack.clear(); } self.detect_mode(); self.reparse(); self.update_markdown(); } } Message::New => self.new_file(), Message::Open => self.open_dialog(), Message::OpenPath(path) => self.open_path(&path), Message::Save => self.save(), Message::SaveAs => self.save_as(), Message::SaveSources => self.save_sources_dialog(), Message::ExportSingle => self.export_single(), Message::ExportIndividual => self.export_individual(), Message::ExportMesh(fmt) => self.export_mesh(fmt), Message::MousePress => { if self.mouse_pos.x > 420.0 { self.viewport.start_drag(self.mouse_pos); } } Message::MouseRelease => { self.viewport.end_drag(); } Message::MouseMove(pos) => { self.mouse_pos = pos; if self.viewport.is_dragging() { self.viewport.drag_to(pos); } } Message::MouseScroll(delta) => { self.viewport.on_scroll(delta); } Message::RightClick => { if self.mouse_pos.x < 420.0 && self.mouse_pos.y > 30.0 { self.context_menu_pos = Some(self.mouse_pos); } } Message::ToggleShadows(v) => self.viewport.render_flags.shadows = v, Message::ToggleAO(v) => self.viewport.render_flags.ao = v, Message::ToggleGround(v) => self.viewport.render_flags.ground = v, Message::Undo => { if self.undo_stack.len() > 1 { let current = self.undo_stack.pop().unwrap(); self.redo_stack.push(current); if let Some(prev) = self.undo_stack.last() { self.source = text_editor::Content::with_text(prev); self.detect_mode(); self.reparse(); self.update_markdown(); } } } Message::Redo => { if let Some(next) = self.redo_stack.pop() { self.undo_stack.push(next.clone()); self.source = text_editor::Content::with_text(&next); self.detect_mode(); self.reparse(); self.update_markdown(); } } Message::CopyText => { if let Some(sel) = self.source.selection() { if let Ok(mut cb) = arboard::Clipboard::new() { let _ = cb.set_text(&sel); } } } Message::CutText => { if let Some(sel) = self.source.selection() { if let Ok(mut cb) = arboard::Clipboard::new() { let _ = cb.set_text(&sel); } self.source.perform(text_editor::Action::Edit( text_editor::Edit::Backspace, )); self.dirty = true; self.detect_mode(); self.reparse(); self.update_markdown(); } } Message::PasteText => { if let Ok(mut cb) = arboard::Clipboard::new() { if let Ok(t) = cb.get_text() { self.source.perform(text_editor::Action::Edit( text_editor::Edit::Paste(Arc::new(t)), )); self.dirty = true; self.detect_mode(); self.reparse(); self.update_markdown(); } } } Message::SelectAll => { self.source.perform(text_editor::Action::SelectAll); } Message::MarkdownUrl(_url) => {} Message::FileDrop(path) => self.open_path(&path), Message::Transform(action) => self.apply_transform(action), Message::ResetView => self.viewport.reset_camera(), Message::SelectObject(name) => { self.selected_object = Some(name); } Message::DismissOverlay => {} Message::RenderObjects => { if self.needs_cast { self.insert_line("cast()"); } } Message::RenderPlots => { if self.needs_plot { self.insert_line("plot()"); } } Message::RenderAll => { let mut added = false; if self.needs_cast { self.insert_line("cast()"); added = true; } if self.needs_plot { self.insert_line("plot()"); added = true; } if !added { self.reparse(); } } Message::DecomposeMesh => self.decompose_mesh(), Message::Tick => { if !self.menu_ready { setup_native_menu(); self.menu_ready = true; } self.poll_menu_events(); let new_line = self.source.cursor().position.line; if new_line != self.cursor_line { self.cursor_line = new_line; self.line_eval_text = self.eval_line_at(new_line); } } } } fn eval_line_at(&self, line: usize) -> Option { let src = self.source.text(); let lines: Vec<&str> = src.lines().collect(); if line >= lines.len() { return None; } let target = lines[line].trim(); if target.is_empty() || target.starts_with("//") || target.starts_with("/*") || target == "cast()" || target == "plot()" { return None; } let partial: String = lines[..=line].join("\n"); match parse_expr(&partial) { Ok(graph) => { let info = classify(&graph); let val = cord_trig::eval::evaluate(&graph, 0.0, 0.0, 0.0); if val.is_finite() { if info.dimensions == 0 { Some(format!("= {}", fmt_val(val).unwrap_or_else(|| "?".into()))) } else { Some(format!("at origin: {}", fmt_val(val).unwrap_or_else(|| "?".into()))) } } else { None } } Err(_) => None, } } fn poll_menu_events(&mut self) { while let Ok(event) = muda::MenuEvent::receiver().try_recv() { let msg = match event.id().as_ref() { "new" => Some(Message::New), "open" => Some(Message::Open), "save" => Some(Message::Save), "save_as" => Some(Message::SaveAs), "save_sources" => Some(Message::SaveSources), "export_single" => Some(Message::ExportSingle), "export_individual" => Some(Message::ExportIndividual), "export_stl" => Some(Message::ExportMesh(MeshFormat::Stl)), "export_3mf" => Some(Message::ExportMesh(MeshFormat::ThreeMf)), "export_step" => Some(Message::ExportMesh(MeshFormat::Step)), "export_scad" => Some(Message::ExportMesh(MeshFormat::Scad)), "undo" => Some(Message::Undo), "redo" => Some(Message::Redo), "decompose" => Some(Message::DecomposeMesh), "render_objects" => Some(Message::RenderObjects), "render_plots" => Some(Message::RenderPlots), "render_all" => Some(Message::RenderAll), _ => None, }; if let Some(msg) = msg { self.update(msg); } } } fn insert_line(&mut self, line: &str) { let src = self.source.text(); let trimmed = src.trim_end(); let new_src = if trimmed.is_empty() { line.to_string() } else { format!("{trimmed}\n{line}") }; self.source = text_editor::Content::with_text(&new_src); self.dirty = true; self.undo_stack.push(new_src); if self.undo_stack.len() > UNDO_LIMIT { self.undo_stack.remove(0); } self.redo_stack.clear(); self.detect_mode(); self.reparse(); self.update_markdown(); } fn detect_mode(&mut self) { if let Some(ext) = self.current_path.as_ref() .and_then(|p| p.extension()) .and_then(|e| e.to_str()) { match ext.to_lowercase().as_str() { "scad" => { self.mode = InputMode::Scad; return; } "crd" | "zcd" | "obj" | "stl" | "3mf" => { self.mode = InputMode::Expr; return; } _ => {} } } let s = self.source.text(); let s = s.trim(); let has_scad_blocks = s.contains('{') && s.contains(';'); let has_scad_keywords = s.starts_with("module ") || s.starts_with("difference") || s.starts_with("union") || s.starts_with("intersection"); if has_scad_keywords || (has_scad_blocks && !s.starts_with("let ") && !s.starts_with("sch ") && !s.starts_with("//")) { self.mode = InputMode::Scad; } else { self.mode = InputMode::Expr; } } fn reparse(&mut self) { let src = self.source.text(); let src = src.trim(); if src.is_empty() { self.info = None; self.error = None; return; } if self.mode == InputMode::Scad { match parse_scad(src) { Ok((graph, bounds)) => { self.info = Some(classify(&graph)); self.error = None; self.viewport.set_graph(&graph); self.viewport.set_bounds(bounds); } Err(e) => { self.info = None; self.error = Some(e); self.viewport.set_graph(&default_sphere_graph()); self.viewport.set_bounds(2.0); } } return; } match parse_expr_scene(src) { Ok(scene) => { let mut graph = scene.graph; let mut sdf_roots: Vec = Vec::new(); let render_objects: Vec<(String, cord_trig::ir::NodeId)> = if scene.cast_all { scene.all_vars.clone() } else if !scene.casts.is_empty() { scene.casts.clone() } else { Vec::new() }; for &(_, id) in &render_objects { sdf_roots.push(id); } let mut plot_nodes: Vec = if scene.plot_all { let mut nodes: Vec<_> = scene.all_vars.iter().map(|(_, id)| *id).collect(); nodes.extend(&scene.bare_exprs); nodes } else { Vec::new() }; plot_nodes.extend(&scene.plots); for &node_id in &plot_nodes { let info = classify_from(&graph, node_id); let sdf = expr_to_sdf(&mut graph, node_id, info.dimensions, 0.05, 10.0); sdf_roots.push(sdf); } if sdf_roots.len() >= 2 { let mut root = sdf_roots[0]; for &node in &sdf_roots[1..] { root = graph.push(cord_trig::TrigOp::Min(root, node)); } graph.set_output(root); } else if sdf_roots.len() == 1 { graph.set_output(sdf_roots[0]); } else { let empty = graph.push(cord_trig::TrigOp::Const(1e10)); graph.set_output(empty); } self.info = Some(classify(&graph)); self.error = None; let has_plots = !plot_nodes.is_empty(); let mut bounds = estimate_bounds(&graph); if has_plots { bounds = bounds.max(10.0); } self.viewport.set_graph(&graph); self.viewport.set_bounds(bounds); self.scene_objects = scene.objects.iter().map(|(n, _)| n.clone()).collect(); self.needs_cast = scene.needs_cast; self.needs_plot = scene.needs_plot; if let Some(ref sel) = self.selected_object { if !self.scene_objects.contains(sel) { self.selected_object = self.scene_objects.first().cloned(); } } else if !self.scene_objects.is_empty() { self.selected_object = self.scene_objects.first().cloned(); } } Err(e) => { self.info = None; self.error = Some(e); 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); } } } fn update_markdown(&mut self) { let src = self.source.text(); let md = build_notebook_md(&src); if md.is_empty() { self.md_items.clear(); } else { self.md_items = markdown::parse(&md).collect(); } } // === File operations === fn new_file(&mut self) { let initial = ""; self.source = text_editor::Content::with_text(initial); self.undo_stack = vec![initial.to_string()]; self.redo_stack.clear(); self.current_path = None; self.mesh_path = None; self.dirty = false; self.mode = InputMode::Expr; self.info = None; self.error = None; self.scene_objects.clear(); self.selected_object = None; self.needs_cast = false; self.needs_plot = false; self.md_items.clear(); self.viewport.set_graph(&default_sphere_graph()); self.viewport.set_bounds(2.0); self.status = Some("new file".into()); } fn decompose_mesh(&mut self) { let path = match self.mesh_path.clone() { Some(p) => p, None => { self.status = Some("no mesh loaded to decompose".into()); return; } }; use cord_decompile::mesh::TriangleMesh; use cord_decompile::{decompile, DecompileConfig}; let mesh = match TriangleMesh::load(&path) { Ok(m) => m, Err(e) => { self.status = Some(format!("mesh load error: {e}")); return; } }; let config = DecompileConfig::default(); let source = match decompile(&mesh, &config) { Ok(result) => cord_sdf::sdf_to_cordial(&result.sdf), Err(e) => { self.status = Some(format!("decompose error: {e}")); return; } }; self.source = text_editor::Content::with_text(&source); self.undo_stack.push(source); if self.undo_stack.len() > UNDO_LIMIT { self.undo_stack.remove(0); } self.redo_stack.clear(); self.dirty = true; self.detect_mode(); self.reparse(); self.update_markdown(); self.status = Some("decomposed mesh to Cordial".into()); } fn open_dialog(&mut self) { let path = rfd::FileDialog::new() .add_filter("All Supported", &["crd", "cord", "zcd", "scad", "stl", "obj", "3mf", "step", "stp"]) .add_filter("Cord Files", &["zcd", "crd", "cord"]) .add_filter("3D Models", &["step", "stp", "obj", "stl", "3mf"]) .add_filter("OpenSCAD", &["scad"]) .add_filter("All Files", &["*"]) .pick_file(); if let Some(p) = path { self.open_path(&p); } } fn open_path(&mut self, path: &std::path::Path) { let ext = path.extension() .and_then(|e| e.to_str()) .unwrap_or("") .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), "crd" | "scad" => std::fs::read_to_string(path).map_err(|e| e.to_string()), "cord" | "bin" => { Err("binary format (.cord) — no source to edit".into()) } "obj" | "stl" | "3mf" => { 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")) } _ => std::fs::read_to_string(path).map_err(|e| e.to_string()), }; match result { Ok(source) => { self.source = text_editor::Content::with_text(&source); self.undo_stack = vec![source]; self.redo_stack.clear(); self.current_path = Some(path.to_path_buf()); self.mesh_path = if is_mesh { Some(path.to_path_buf()) } else { None }; self.dirty = false; self.detect_mode(); self.reparse(); self.update_markdown(); self.push_recent(path); let note = fallback_note.unwrap_or(""); self.status = Some(format!("opened: {}{note}", path.display())); } Err(e) => { self.status = Some(format!("error: {e}")); } } } fn save(&mut self) { if let Some(path) = self.current_path.clone() { self.save_to(&path); } else { self.save_as(); } } fn save_as(&mut self) { let default_name = self.current_path.as_ref() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .unwrap_or("scene.zcd") .to_string(); let path = rfd::FileDialog::new() .set_file_name(&default_name) .add_filter("Cord Archive", &["zcd"]) .add_filter("Cord Source", &["crd"]) .add_filter("OpenSCAD", &["scad"]) .save_file(); if let Some(p) = path { self.save_to(&p); } } fn save_to(&mut self, path: &std::path::Path) { let ext = path.extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_lowercase(); let src = self.source.text(); let src_trimmed = src.trim(); let result = match ext.as_str() { "zcd" => { let graph = self.current_graph(); match graph { Some(g) => { let wgsl = cord_shader::generate_wgsl_from_trig(&g); let trig_bytes = g.to_bytes(); let config = cord_cordic::compiler::CompileConfig::default(); let cordic = cord_cordic::CORDICProgram::compile(&g, &config); let cordic_bytes = cordic.to_bytes(); write_zcd(path, src_trimmed, &wgsl, &trig_bytes, &cordic_bytes, self.mode) } None => Err("cannot compile — fix errors first".into()), } } "crd" | "scad" | _ => { std::fs::write(path, src_trimmed).map_err(|e| e.to_string()) } }; match result { Ok(()) => { self.current_path = Some(path.to_path_buf()); self.dirty = false; self.push_recent(path); self.status = Some(format!("saved: {}", path.display())); } Err(e) => { self.status = Some(format!("error: {e}")); } } } fn save_sources_dialog(&mut self) { let src = self.source.text(); if src.trim().is_empty() { self.status = Some("nothing to save".into()); return; } let result = rfd::MessageDialog::new() .set_title("Save Sources") .set_description( "Save all objects as a single file or individual files?\n\n\ You can also use \u{2318}E for single file or \u{21e7}\u{2318}E for individual files." ) .set_buttons(rfd::MessageButtons::OkCancelCustom( "Single File".into(), "Individual Files".into(), )) .show(); match result { rfd::MessageDialogResult::Ok => self.export_single(), rfd::MessageDialogResult::Cancel => self.export_individual(), _ => {} } } fn export_single(&mut self) { let src = self.source.text(); let src_trimmed = src.trim(); if src_trimmed.is_empty() { self.status = Some("nothing to export".into()); return; } let default_name = self.current_path.as_ref() .and_then(|p| p.file_stem()) .and_then(|n| n.to_str()) .unwrap_or("scene"); let path = rfd::FileDialog::new() .set_file_name(&format!("{default_name}.crd")) .add_filter("Cord Source", &["crd"]) .add_filter("OpenSCAD", &["scad"]) .save_file(); if let Some(p) = path { match std::fs::write(&p, src_trimmed) { Ok(()) => self.status = Some(format!("saved: {}", p.display())), Err(e) => self.status = Some(format!("error: {e}")), } } } fn export_individual(&mut self) { let src = self.source.text(); let src_trimmed = src.trim(); if src_trimmed.is_empty() { self.status = Some("nothing to export".into()); return; } if self.scene_objects.is_empty() { self.status = Some("no named objects to export individually".into()); return; } let folder = rfd::FileDialog::new() .set_title("Choose folder for individual source files") .pick_folder(); let folder = match folder { Some(f) => f, None => return, }; let lines: Vec<&str> = src_trimmed.lines().collect(); let base_src: String = lines.iter() .filter(|l| { let t = l.trim(); t != "cast()" && t != "plot()" && !t.starts_with("cast(") && !t.starts_with("plot(") }) .copied() .collect::>() .join("\n"); let mut count = 0; for name in &self.scene_objects { let content = format!("{base_src}\ncast({name})"); let path = folder.join(format!("{name}.crd")); match std::fs::write(&path, &content) { Ok(()) => count += 1, Err(e) => { self.status = Some(format!("error writing {name}.crd: {e}")); return; } } } self.status = Some(format!("saved {count} source files to {}", folder.display())); } fn export_mesh(&mut self, format: MeshFormat) { let graph = match self.current_graph() { Some(g) => g, None => { self.status = Some("cannot export — fix errors first".into()); return; } }; let (ext, filter_name) = match format { MeshFormat::Stl => ("stl", "STL Mesh"), MeshFormat::ThreeMf => ("3mf", "3MF Archive"), MeshFormat::Step => ("step", "STEP File"), MeshFormat::Scad => ("scad", "OpenSCAD"), }; if matches!(format, MeshFormat::Scad) { let src = self.source.text(); let path = rfd::FileDialog::new() .set_file_name(&format!("scene.{ext}")) .add_filter(filter_name, &[ext]) .save_file(); if let Some(p) = path { let scad_src = if self.mode == InputMode::Scad { src.trim().to_string() } else { cordial_to_scad(src.trim()) }; match std::fs::write(&p, &scad_src) { Ok(()) => self.status = Some(format!("exported: {}", p.display())), Err(e) => self.status = Some(format!("error: {e}")), } } return; } let bounds = estimate_bounds(&graph); let resolution = 64u32; let mesh = marching_cubes(&graph, bounds, resolution); if mesh.is_empty() { self.status = Some("mesh generation produced no triangles".into()); return; } let path = rfd::FileDialog::new() .set_file_name(&format!("scene.{ext}")) .add_filter(filter_name, &[ext]) .save_file(); let path = match path { Some(p) => p, None => return, }; let result = match format { MeshFormat::Stl => write_stl_binary(&path, &mesh), MeshFormat::ThreeMf => write_3mf(&path, &mesh), MeshFormat::Step => { Err("STEP export not yet implemented".into()) } MeshFormat::Scad => unreachable!(), }; match result { Ok(()) => self.status = Some(format!("exported: {}", path.display())), Err(e) => self.status = Some(format!("error: {e}")), } } fn current_graph(&self) -> Option { let src = self.source.text(); let src = src.trim(); if src.is_empty() { return None; } if self.mode == InputMode::Scad { parse_scad(src).ok().map(|(g, _)| g) } else { parse_expr(src).ok() } } fn push_recent(&mut self, path: &std::path::Path) { let pb = path.to_path_buf(); self.recents.retain(|p| p != &pb); self.recents.insert(0, pb); self.recents.truncate(MAX_RECENTS); save_recents(&self.recents); } fn apply_transform(&mut self, action: TransformAction) { let src = self.source.text(); let trimmed = src.trim(); if trimmed.is_empty() { return; } let lines: Vec<&str> = trimmed.lines().collect(); let target_idx = if let Some(ref obj_name) = self.selected_object { find_object_line(&lines, obj_name) } else { None }; let target_idx = target_idx.unwrap_or_else(|| { lines.iter().rposition(|l| { let t = l.trim(); !t.is_empty() && !t.starts_with("//") && !t.starts_with("/*") }).unwrap_or(lines.len() - 1) }); let target_line = lines[target_idx].trim(); let (line_prefix, effective_target) = if let Some(semi_pos) = target_line.rfind(';') { let after = target_line[semi_pos + 1..].trim(); if after.is_empty() { ("", target_line.trim_end_matches(';').trim()) } else { (&target_line[..semi_pos + 1], after) } } else { ("", target_line) }; let (is_let_binding, let_prefix, expr_part) = if effective_target.starts_with("let ") { if let Some(eq_pos) = effective_target.find('=') { let before_eq = &effective_target[..eq_pos + 1]; let after_eq = effective_target[eq_pos + 1..].trim().trim_end_matches(';'); (true, before_eq.to_string(), after_eq.to_string()) } else { (false, String::new(), effective_target.trim_end_matches(';').to_string()) } } else { (false, String::new(), effective_target.trim_end_matches(';').to_string()) }; let new_expr = apply_transform_collapsed(&expr_part, &action); let wrapped = if is_let_binding { format!("{let_prefix} {new_expr}") } else { new_expr }; let new_line = if line_prefix.is_empty() { wrapped } else { format!("{line_prefix} {wrapped}") }; let mut result_lines: Vec<&str> = lines[..target_idx].to_vec(); let new_line_ref = new_line.as_str(); result_lines.push(new_line_ref); result_lines.extend_from_slice(&lines[target_idx + 1..]); let new_src = result_lines.join("\n"); self.source = text_editor::Content::with_text(&new_src); self.dirty = true; let snap = new_src.clone(); self.undo_stack.push(snap); if self.undo_stack.len() > UNDO_LIMIT { self.undo_stack.remove(0); } self.redo_stack.clear(); self.detect_mode(); self.reparse(); self.update_markdown(); } pub fn subscription(&self) -> Subscription { let events = iced::event::listen_with(|event, status, _id| { let captured = matches!(status, iced::event::Status::Captured); match event { iced::Event::Keyboard(keyboard::Event::KeyPressed { key: keyboard::Key::Character(c), modifiers, .. }) if modifiers.command() => { match c.as_ref() { "z" if modifiers.shift() => Some(Message::Redo), "z" => Some(Message::Undo), "n" => Some(Message::New), "o" => Some(Message::Open), "s" if modifiers.shift() => Some(Message::SaveAs), "s" => Some(Message::Save), "e" if modifiers.shift() => Some(Message::ExportIndividual), "e" => Some(Message::ExportSingle), "c" if !captured => Some(Message::CopyText), "x" if !captured => Some(Message::CutText), "v" if !captured => Some(Message::PasteText), "a" if !captured => Some(Message::SelectAll), "r" => Some(Message::RenderAll), "2" => Some(Message::RenderPlots), "3" => Some(Message::RenderObjects), _ => None, } } iced::Event::Window(window::Event::FileDropped(path)) => { Some(Message::FileDrop(path)) } iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) if !captured => { Some(Message::MousePress) } iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => { Some(Message::MouseRelease) } iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right)) if !captured => { Some(Message::RightClick) } iced::Event::Mouse(mouse::Event::CursorMoved { position }) => { Some(Message::MouseMove(position)) } iced::Event::Mouse(mouse::Event::WheelScrolled { delta }) => { let y = match delta { mouse::ScrollDelta::Lines { y, .. } => y, mouse::ScrollDelta::Pixels { y, .. } => y / 50.0, }; Some(Message::MouseScroll(y)) } _ => None, } }); let tick = iced::time::every(Duration::from_millis(100)).map(|_| Message::Tick); Subscription::batch([events, tick]) } pub fn view(&self) -> Element<'_, Message> { let has_md = !self.md_items.is_empty(); let has_recents = !self.recents.is_empty() && self.current_path.is_none() && self.source.text().trim() == "let s = sphere(3)\ncast()"; let editor_height: Length = if has_md { Length::FillPortion(3) } else { Fill }; let editor = text_editor(&self.source) .on_action(Message::EditorAction) .highlight_with::(CordHighlighterSettings, format_token) .padding(10) .size(14) .height(editor_height); let editor_el: Element = editor.into(); // Status line let mode_label = match self.mode { InputMode::Expr => "expr", InputMode::Scad => "scad", }; let mut status_parts = mode_label.to_string(); if let Some(ref info) = self.info { status_parts += &format!( " | {} | {}n | {}C", info.dimension_label(), info.node_count, info.total_cordic_passes(), ); } if let Some(ref tip) = self.line_eval_text { status_parts += &format!(" | ln {}: {tip}", self.cursor_line + 1); } let mut status_line: Vec> = vec![ text(status_parts).size(11).color([0.6, 0.6, 0.6]).into(), ]; if let Some(ref e) = self.error { status_line.push( text(e.clone()).size(11).color([1.0, 0.4, 0.4]).into() ); } if let Some(ref s) = self.status { status_line.push( text(s.clone()).size(10).color([0.5, 0.8, 0.5]).into() ); } let rf = &self.viewport.render_flags; let toggles = row![ ttip( checkbox(rf.shadows).label("Shad").on_toggle(Message::ToggleShadows).text_size(10), "Toggle soft shadows", ), ttip( checkbox(rf.ao).label("AO").on_toggle(Message::ToggleAO).text_size(10), "Toggle ambient occlusion", ), ttip( checkbox(rf.ground).label("Gnd").on_toggle(Message::ToggleGround).text_size(10), "Toggle ground plane", ), ] .spacing(8); let bottom_bar = row![ column(status_line).spacing(2).width(Fill), toggles, ] .spacing(8) .align_y(iced::Alignment::End); let mut sidebar_parts: Vec> = vec![ editor_el, ]; // Recents panel if has_recents { let mut recents_col: Vec> = vec![ text("Recent files").size(12).color([0.6, 0.6, 0.6]).into(), ]; for path in &self.recents { let label = path.file_name() .and_then(|n| n.to_str()) .unwrap_or("?") .to_string(); let p = path.clone(); recents_col.push( button(text(label).size(11)) .on_press(Message::OpenPath(p)) .padding([2, 8]) .into() ); } sidebar_parts.push( column(recents_col).spacing(4).into() ); } if !self.md_items.is_empty() { let md_view = markdown::view(&self.md_items, iced::Theme::Dark) .map(Message::MarkdownUrl); let md_scroll = scrollable(md_view) .height(Length::FillPortion(1)); sidebar_parts.push(md_scroll.into()); } sidebar_parts.push(bottom_bar.into()); let sidebar = column(sidebar_parts) .spacing(6) .padding(10) .width(Length::Fixed(420.0)) .height(Fill); let viewport_el: Element = Shader::new(&self.viewport).width(Fill).height(Fill).into(); // Object selector bar let obj_bar: Element = if !self.scene_objects.is_empty() { let mut obj_buttons: Vec> = Vec::new(); for name in &self.scene_objects { let is_selected = self.selected_object.as_ref() == Some(name); let n = name.clone(); let tip_text = format!("Select {n} for transforms"); obj_buttons.push( ttip( button(text(name.clone()).size(13).center().color( if is_selected { Color::from_rgb(0.95, 0.85, 0.2) } else { Color::from_rgb(0.85, 0.85, 0.85) } )) .on_press(Message::SelectObject(name.clone())) .padding([6, 16]) .style(move |_theme, status| { let bg = if is_selected { Color::from_rgb(0.15, 0.42, 0.48) } else { match status { button::Status::Hovered => Color::from_rgb(0.18, 0.38, 0.42), _ => Color::from_rgb(0.12, 0.30, 0.35), } }; button::Style { background: Some(Background::Color(bg)), text_color: Color::WHITE, border: Border::default().rounded(4), shadow: Shadow::default(), snap: false, } }), &tip_text, ) ); } row(obj_buttons).spacing(6).padding([4, 6]).width(Fill).into() } else { Space::new().into() }; // Transform toolbar with colored axis buttons let t = |x: f32, y: f32, z: f32| Message::Transform(TransformAction::Translate(x, y, z)); let x_color = Color::from_rgb(0.78, 0.22, 0.22); let y_color = Color::from_rgb(0.22, 0.65, 0.22); let z_color = Color::from_rgb(0.28, 0.40, 0.82); let gray = Color::from_rgb(0.28, 0.28, 0.30); let transform_bar = row![ axis_btn("X+", t(1.0, 0.0, 0.0), x_color, "Translate +1 along X"), axis_btn("X-", t(-1.0, 0.0, 0.0), x_color, "Translate -1 along X"), container(Space::new()).width(8), axis_btn("Y+", t(0.0, 1.0, 0.0), y_color, "Translate +1 along Y"), axis_btn("Y-", t(0.0, -1.0, 0.0), y_color, "Translate -1 along Y"), container(Space::new()).width(8), axis_btn("Z+", t(0.0, 0.0, 1.0), z_color, "Translate +1 along Z"), axis_btn("Z-", t(0.0, 0.0, -1.0), z_color, "Translate -1 along Z"), container(Space::new()).width(12), axis_btn("Rx", Message::Transform(TransformAction::RotateX), gray, "Rotate 30\u{00b0} around X"), axis_btn("Ry", Message::Transform(TransformAction::RotateY), gray, "Rotate 30\u{00b0} around Y"), axis_btn("Rz", Message::Transform(TransformAction::RotateZ), gray, "Rotate 30\u{00b0} around Z"), container(Space::new()).width(6), axis_btn("S+", Message::Transform(TransformAction::ScaleUp), gray, "Scale up 1.5\u{00d7}"), axis_btn("S-", Message::Transform(TransformAction::ScaleDown), gray, "Scale down 0.667\u{00d7}"), container(Space::new()).width(6), axis_btn("Mx", Message::Transform(TransformAction::MirrorX), gray, "Mirror across YZ plane"), axis_btn("My", Message::Transform(TransformAction::MirrorY), gray, "Mirror across XZ plane"), axis_btn("Mz", Message::Transform(TransformAction::MirrorZ), gray, "Mirror across XY plane"), container(Space::new()).width(6), axis_btn("Rst", Message::ResetView, gray, "Reset camera view"), ] .spacing(2) .padding([2, 4]) .width(Fill); let base_layout = column![ row![sidebar, viewport_el].height(Fill), obj_bar, transform_bar, ]; let mut layers: Vec> = vec![base_layout.into()]; if self.context_menu_pos.is_some() { layers.push( mouse_area(container(Space::new()).width(Fill).height(Fill)) .on_press(Message::DismissOverlay) .into() ); } else { layers.push(container(Space::new()).width(0).height(0).into()); } if let Some(pos) = self.context_menu_pos { layers.push(render_context_menu(pos)); } else { layers.push(container(Space::new()).width(0).height(0).into()); } stack(layers).into() } } // === Context menu helpers === fn menu_item<'a>(label: &str, shortcut: &str, msg: Message) -> Element<'a, Message> { button( row![ text(label.to_string()).size(13).width(Fill), text(shortcut.to_string()).size(11).color(Color::from_rgb(0.5, 0.5, 0.5)), ] .spacing(16) .align_y(iced::Alignment::Center) ) .on_press(msg) .padding([4, 12]) .width(Length::Fixed(210.0)) .style(|_theme, status| { let bg = match status { button::Status::Hovered => Color::from_rgba(1.0, 1.0, 1.0, 0.1), _ => Color::TRANSPARENT, }; button::Style { background: Some(Background::Color(bg)), text_color: Color::from_rgb(0.88, 0.88, 0.88), border: Border::default().rounded(2), shadow: Shadow::default(), snap: false, } }) .into() } fn menu_sep<'a>() -> Element<'a, Message> { container(rule::horizontal(1)) .padding([2, 8]) .width(Length::Fixed(210.0)) .into() } fn dropdown_container(items: Vec>) -> Element<'_, Message> { container(column(items).spacing(1)) .padding(4) .style(|_theme| container::Style { background: Some(Background::Color(Color::from_rgba(0.14, 0.14, 0.17, 0.97))), border: Border { color: Color::from_rgb(0.3, 0.3, 0.35), width: 1.0, radius: 6.0.into(), }, shadow: Shadow { color: Color::from_rgba(0.0, 0.0, 0.0, 0.4), offset: Vector::new(2.0, 4.0), blur_radius: 12.0, }, ..Default::default() }) .into() } fn render_context_menu(pos: Point) -> Element<'static, Message> { let items = vec![ menu_item("Undo", "\u{2318}Z", Message::Undo), menu_item("Redo", "\u{21e7}\u{2318}Z", Message::Redo), menu_sep(), menu_item("Cut", "\u{2318}X", Message::CutText), menu_item("Copy", "\u{2318}C", Message::CopyText), menu_item("Paste", "\u{2318}V", Message::PasteText), menu_sep(), menu_item("Select All", "\u{2318}A", Message::SelectAll), ]; let menu = dropdown_container(items); let top = pos.y.max(0.0); let left = pos.x.max(0.0); container(menu) .width(Fill) .height(Fill) .padding(Padding { top, right: 0.0, bottom: 0.0, left }) .into() } // === Styled button helpers === fn axis_btn<'a>(label: &str, msg: Message, color: Color, tip: &str) -> Element<'a, Message> { ttip( button(text(label.to_string()).size(13).center()) .on_press(msg) .padding([6, 0]) .width(Fill) .style(move |_theme, status| { let bg = match status { button::Status::Hovered | button::Status::Pressed => { Color::from_rgb( (color.r * 1.3).min(1.0), (color.g * 1.3).min(1.0), (color.b * 1.3).min(1.0), ) } _ => color, }; button::Style { background: Some(Background::Color(bg)), text_color: Color::WHITE, border: Border::default().rounded(3), shadow: Shadow::default(), snap: false, } }), tip, ) } fn ttip<'a>(content: impl Into>, tip: &str) -> Element<'a, Message> { tooltip( content, container(text(tip.to_string()).size(11)) .padding([2, 6]) .style(|_theme| container::Style { background: Some(Background::Color(Color::from_rgba(0.1, 0.1, 0.13, 0.95))), border: Border { color: Color::from_rgb(0.3, 0.3, 0.35), width: 1.0, radius: 4.0.into(), }, ..Default::default() }), tooltip::Position::Top, ) .delay(Duration::from_secs(3)) .into() } // === Free functions === fn build_notebook_md(src: &str) -> String { let mut md = String::new(); let mut code_lines = String::new(); let mut in_block_comment = false; let mut block_buf = String::new(); for line in src.lines() { if in_block_comment { if let Some(end) = line.find("*/") { block_buf.push_str(&line[..end]); let block = block_buf.trim(); if !block.is_empty() { if !md.is_empty() { md.push('\n'); } md.push_str(block); } block_buf.clear(); in_block_comment = false; let rest = &line[end + 2..]; if !rest.trim().is_empty() { code_lines.push_str(rest); code_lines.push('\n'); } } else { block_buf.push_str(line); block_buf.push('\n'); } continue; } let trimmed = line.trim(); if trimmed.starts_with("/=") { let expr_text = trimmed[2..].trim(); if !expr_text.is_empty() { let full = format!("{code_lines}{expr_text}"); let result = match parse_expr(&full) { Ok(graph) => format_multi_eval(&graph), Err(e) => format!("*error: {e}*"), }; if !md.is_empty() { md.push_str("\n\n"); } md.push_str(&format!("`{expr_text}` = {result}")); } continue; } if trimmed.starts_with("//") { let text = trimmed[2..].strip_prefix(' ').unwrap_or(&trimmed[2..]); if !md.is_empty() { md.push_str("\n\n"); } md.push_str(text); continue; } if let Some(start) = trimmed.find("/*") { let before = &line[..line.find("/*").unwrap()]; if !before.trim().is_empty() { code_lines.push_str(before); code_lines.push('\n'); } let after = &trimmed[start + 2..]; if let Some(end) = after.find("*/") { let text = after[..end].trim(); if !text.is_empty() { if !md.is_empty() { md.push('\n'); } md.push_str(text); } let rest = &after[end + 2..]; if !rest.trim().is_empty() { code_lines.push_str(rest); code_lines.push('\n'); } } else { in_block_comment = true; let text = after.strip_prefix(' ').unwrap_or(after); block_buf.push_str(text); block_buf.push('\n'); } continue; } code_lines.push_str(line); code_lines.push('\n'); } md } fn fmt_val(val: f64) -> Option { if val.is_nan() || val.is_infinite() { None } else if val == val.round() && val.abs() < 1e12 { Some(format!("{}", val as i64)) } else { Some(format!("{val:.6}")) } } fn format_multi_eval(graph: &cord_trig::TrigGraph) -> String { let info = classify(graph); let eval = |x, y, z| cord_trig::eval::evaluate(graph, x, y, z); let origin = eval(0.0, 0.0, 0.0); if info.dimensions == 0 { return fmt_val(origin) .map(|s| format!("**{s}**")) .unwrap_or_else(|| "**NaN**".into()); } let samples: [(f64, f64, f64); 6] = [ (1.0, 0.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, 1.0), (0.0, 0.0, -1.0), ]; let vals: Vec = std::iter::once(origin) .chain(samples.iter().map(|&(x, y, z)| eval(x, y, z))) .collect(); let finite: Vec<&f64> = vals.iter().filter(|v| v.is_finite()).collect(); if finite.is_empty() { return "**NaN**".into(); } if finite.windows(2).all(|w| (w[0] - w[1]).abs() < 1e-15) { return fmt_val(*finite[0]) .map(|s| format!("**{s}**")) .unwrap_or_else(|| "**NaN**".into()); } let mut parts = Vec::new(); if let Some(s) = fmt_val(origin) { parts.push(format!("origin: **{s}**")); } let axis_labels = [ (0, 1, "x", info.uses_x), (2, 3, "y", info.uses_y), (4, 5, "z", info.uses_z), ]; for &(pos_i, neg_i, label, used) in &axis_labels { if !used { continue; } let vp = vals[pos_i + 1]; let vn = vals[neg_i + 1]; match (fmt_val(vp), fmt_val(vn)) { (Some(a), Some(b)) if a == b => { parts.push(format!("\u{00b1}{label}: **{a}**")); } (Some(a), Some(b)) => { parts.push(format!("\u{00b1}{label}: **{a}**, **{b}**")); } (Some(a), None) => parts.push(format!("+{label}: **{a}**")), (None, Some(b)) => parts.push(format!("-{label}: **{b}**")), (None, None) => {} } } parts.join(" | ") } fn write_zcd( path: &std::path::Path, source: &str, wgsl: &str, trig_bytes: &[u8], cordic_bytes: &[u8], mode: InputMode, ) -> Result<(), String> { use cord_format::write::ZcdWriter; let file = std::fs::File::create(path).map_err(|e| e.to_string())?; let buf = std::io::BufWriter::new(file); let mut writer = ZcdWriter::new(buf); match mode { InputMode::Expr => writer.write_source_crd(source).map_err(|e| e.to_string())?, InputMode::Scad => writer.write_source_scad(source).map_err(|e| e.to_string())?, } writer.write_trig(trig_bytes).map_err(|e| e.to_string())?; writer.write_shader(wgsl).map_err(|e| e.to_string())?; writer.write_cordic(cordic_bytes, 32).map_err(|e| e.to_string())?; writer.finish().map_err(|e| e.to_string())?; Ok(()) } fn estimate_bounds(graph: &cord_trig::TrigGraph) -> f64 { let eval = |x, y, z| cord_trig::eval::evaluate(graph, x, y, z); let directions: [(f64, f64, f64); 6] = [ (1.0, 0.0, 0.0), (-1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, -1.0, 0.0), (0.0, 0.0, 1.0), (0.0, 0.0, -1.0), ]; let mut max_r = 1.0_f64; for &(dx, dy, dz) in &directions { let mut r = 1.0; for _ in 0..20 { let v = eval(dx * r, dy * r, dz * r); if v.is_finite() && v > 0.0 { break; } r *= 2.0; } if r > max_r { max_r = r; } } let diag = 1.0 / 3.0_f64.sqrt(); let diagonals: [(f64, f64, f64); 8] = [ (diag, diag, diag), (diag, diag, -diag), (diag, -diag, diag), (diag, -diag, -diag), (-diag, diag, diag), (-diag, diag, -diag), (-diag, -diag, diag), (-diag, -diag, -diag), ]; for &(dx, dy, dz) in &diagonals { let mut r = 1.0; for _ in 0..20 { let v = eval(dx * r, dy * r, dz * r); if v.is_finite() && v > 0.0 { break; } r *= 2.0; } if r > max_r { max_r = r; } } 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; use cord_sdf::lower::lower_program; use cord_sdf::sdf_to_trig; let tokens = Lexer::new(src).tokenize().map_err(|e| e.to_string())?; let program = Parser::new(tokens).parse_program().map_err(|e| e.to_string())?; let sdf = lower_program(&program).map_err(|e| e.to_string())?; let bounds = sdf.bounding_radius(); let graph = sdf_to_trig(&sdf); Ok((graph, bounds)) } fn load_zcd(path: &std::path::Path) -> Result { use cord_format::read::ZcdReader; let file = std::fs::File::open(path).map_err(|e| e.to_string())?; let buf = std::io::BufReader::new(file); let mut reader = ZcdReader::new(buf).map_err(|e| e.to_string())?; if let Ok(Some(source)) = reader.read_source() { return Ok(source); } if let Ok(Some(trig_bytes)) = reader.read_trig() { if let Some(_graph) = cord_trig::TrigGraph::from_bytes(&trig_bytes) { return Err("no source in .zcd (has TrigGraph IR but no decompiler to source yet)".into()); } } Err("no readable layers in .zcd".into()) } 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(MeshImport { source: cord_sdf::sdf_to_cordial(&result.sdf), is_fallback: false, }), Err(_) => Ok(MeshImport { source: mesh_bounding_source(&mesh), is_fallback: true, }), } } fn mesh_bounding_source(mesh: &cord_decompile::mesh::TriangleMesh) -> String { let b = &mesh.bounds; let cx = (b.min.x + b.max.x) / 2.0; let cy = (b.min.y + b.max.y) / 2.0; let cz = (b.min.z + b.max.z) / 2.0; let hx = (b.max.x - b.min.x) / 2.0; let hy = (b.max.y - b.min.y) / 2.0; let hz = (b.max.z - b.min.z) / 2.0; format!( "// bounding box approximation (decompose failed)\n\ let result: Obj = translate(box({hx:.4}, {hy:.4}, {hz:.4}), {cx:.4}, {cy:.4}, {cz:.4})\n\ cast(result)\n" ) } fn find_object_line(lines: &[&str], name: &str) -> Option { for (i, line) in lines.iter().enumerate() { let t = line.trim(); if t.starts_with("let ") { let rest = t[4..].trim(); if rest.starts_with(name) { let after_name = rest[name.len()..].trim(); if after_name.starts_with('=') || after_name.starts_with(':') { return Some(i); } } } else if t.starts_with(name) { let after = t[name.len()..].trim(); if after.starts_with('=') && !after.starts_with("==") { return Some(i); } } } None } fn split_outer_call(expr: &str) -> Option<(&str, &str, &str)> { let paren = expr.find('(')?; let func = expr[..paren].trim(); if !expr.ends_with(')') { return None; } let body = &expr[paren + 1..expr.len() - 1]; let mut depth = 0; for (i, c) in body.char_indices() { match c { '(' => depth += 1, ')' => depth -= 1, ',' if depth == 0 => { return Some((func, body[..i].trim(), body[i + 1..].trim())); } _ => {} } } Some((func, body.trim(), "")) } const TRANSFORM_FUNCS: &[&str] = &[ "translate", "rotate_x", "rotate_y", "rotate_z", "scale", "mirror_x", "mirror_y", "mirror_z", "rx", "ry", "rz", "mx", "my", "mz", "mov", "move", ]; #[derive(Default)] struct TransformStack { translate: Option<(f32, f32, f32)>, rotate_x: Option, rotate_y: Option, rotate_z: Option, scale: Option, mirror_x: bool, mirror_y: bool, mirror_z: bool, } fn peel_transforms<'a>(expr: &'a str, stack: &mut TransformStack) -> &'a str { let mut current = expr.trim(); loop { let Some((func, inner, args)) = split_outer_call(current) else { break }; if !TRANSFORM_FUNCS.contains(&func) { break; } match func { "translate" | "mov" | "move" => { let parts: Vec<&str> = args.splitn(3, ',').collect(); if parts.len() != 3 { break; } let Ok(tx) = parts[0].trim().parse::() else { break }; let Ok(ty) = parts[1].trim().parse::() else { break }; let Ok(tz) = parts[2].trim().parse::() else { break }; let (ox, oy, oz) = stack.translate.unwrap_or((0.0, 0.0, 0.0)); stack.translate = Some((ox + tx, oy + ty, oz + tz)); } "rotate_x" | "rx" => { let Ok(a) = args.trim().parse::() else { break }; stack.rotate_x = Some(stack.rotate_x.unwrap_or(0.0) + a); } "rotate_y" | "ry" => { let Ok(a) = args.trim().parse::() else { break }; stack.rotate_y = Some(stack.rotate_y.unwrap_or(0.0) + a); } "rotate_z" | "rz" => { let Ok(a) = args.trim().parse::() else { break }; stack.rotate_z = Some(stack.rotate_z.unwrap_or(0.0) + a); } "scale" => { let Ok(s) = args.trim().parse::() else { break }; stack.scale = Some(stack.scale.unwrap_or(1.0) * s); } "mirror_x" | "mx" => stack.mirror_x = !stack.mirror_x, "mirror_y" | "my" => stack.mirror_y = !stack.mirror_y, "mirror_z" | "mz" => stack.mirror_z = !stack.mirror_z, _ => break, } current = inner; } current } fn apply_action(stack: &mut TransformStack, action: &TransformAction) { match action { TransformAction::Translate(dx, dy, dz) => { let (ox, oy, oz) = stack.translate.unwrap_or((0.0, 0.0, 0.0)); stack.translate = Some((ox + dx, oy + dy, oz + dz)); } TransformAction::RotateX => { stack.rotate_x = Some(stack.rotate_x.unwrap_or(0.0) + std::f32::consts::FRAC_PI_6); } TransformAction::RotateY => { stack.rotate_y = Some(stack.rotate_y.unwrap_or(0.0) + std::f32::consts::FRAC_PI_6); } TransformAction::RotateZ => { stack.rotate_z = Some(stack.rotate_z.unwrap_or(0.0) + std::f32::consts::FRAC_PI_6); } TransformAction::ScaleUp => { stack.scale = Some(stack.scale.unwrap_or(1.0) * 1.5); } TransformAction::ScaleDown => { stack.scale = Some(stack.scale.unwrap_or(1.0) * 0.667); } TransformAction::MirrorX => stack.mirror_x = !stack.mirror_x, TransformAction::MirrorY => stack.mirror_y = !stack.mirror_y, TransformAction::MirrorZ => stack.mirror_z = !stack.mirror_z, } } // Order: mirror -> scale -> rotate -> translate (inside-out) fn recompose_transforms(inner: &str, stack: &TransformStack) -> String { let mut result = inner.to_string(); if stack.mirror_x { result = format!("mirror_x({result})"); } if stack.mirror_y { result = format!("mirror_y({result})"); } if stack.mirror_z { result = format!("mirror_z({result})"); } if let Some(s) = stack.scale { if (s - 1.0).abs() > 1e-6 { result = format!("scale({result}, {s:.4})"); } } if let Some(a) = stack.rotate_x { if a.abs() > 1e-6 { result = format!("rotate_x({result}, {a:.6})"); } } if let Some(a) = stack.rotate_y { if a.abs() > 1e-6 { result = format!("rotate_y({result}, {a:.6})"); } } if let Some(a) = stack.rotate_z { if a.abs() > 1e-6 { result = format!("rotate_z({result}, {a:.6})"); } } if let Some((tx, ty, tz)) = stack.translate { if tx.abs() > 1e-6 || ty.abs() > 1e-6 || tz.abs() > 1e-6 { result = format!("translate({result}, {tx}, {ty}, {tz})"); } } result } fn apply_transform_collapsed(expr: &str, action: &TransformAction) -> String { let mut stack = TransformStack::default(); let inner = peel_transforms(expr, &mut stack); apply_action(&mut stack, action); recompose_transforms(inner, &stack) } fn cordial_to_scad(src: &str) -> String { let mut out = String::new(); for line in src.lines() { let t = line.trim(); if t.is_empty() || t.starts_with("//") || t == "cast()" || t == "plot()" { out.push_str(line); out.push('\n'); continue; } if t.starts_with("cast(") || t.starts_with("plot(") { continue; } out.push_str(line); out.push('\n'); } out } #[derive(Clone)] struct Triangle { v: [[f32; 3]; 3], n: [f32; 3], } fn marching_cubes(graph: &cord_trig::TrigGraph, bounds: f64, res: u32) -> Vec { let step = (bounds * 2.2) / res as f64; let origin = -bounds * 1.1; let n = res + 1; let mut field = vec![0.0f64; (n * n * n) as usize]; for iz in 0..n { for iy in 0..n { for ix in 0..n { let x = origin + ix as f64 * step; let y = origin + iy as f64 * step; let z = origin + iz as f64 * step; let idx = (iz * n * n + iy * n + ix) as usize; field[idx] = cord_trig::eval::evaluate(graph, x, y, z); } } } let mut tris = Vec::new(); let idx = |ix: u32, iy: u32, iz: u32| (iz * n * n + iy * n + ix) as usize; for iz in 0..res { for iy in 0..res { for ix in 0..res { let corners = [ idx(ix, iy, iz), idx(ix + 1, iy, iz), idx(ix + 1, iy + 1, iz), idx(ix, iy + 1, iz), idx(ix, iy, iz + 1), idx(ix + 1, iy, iz + 1), idx(ix + 1, iy + 1, iz + 1), idx(ix, iy + 1, iz + 1), ]; let vals: [f64; 8] = std::array::from_fn(|i| field[corners[i]]); let mut cube_idx = 0u8; for i in 0..8 { if vals[i] < 0.0 { cube_idx |= 1 << i; } } if cube_idx == 0 || cube_idx == 255 { continue; } let pos: [[f64; 3]; 8] = [ [origin + ix as f64 * step, origin + iy as f64 * step, origin + iz as f64 * step], [origin + (ix + 1) as f64 * step, origin + iy as f64 * step, origin + iz as f64 * step], [origin + (ix + 1) as f64 * step, origin + (iy + 1) as f64 * step, origin + iz as f64 * step], [origin + ix as f64 * step, origin + (iy + 1) as f64 * step, origin + iz as f64 * step], [origin + ix as f64 * step, origin + iy as f64 * step, origin + (iz + 1) as f64 * step], [origin + (ix + 1) as f64 * step, origin + iy as f64 * step, origin + (iz + 1) as f64 * step], [origin + (ix + 1) as f64 * step, origin + (iy + 1) as f64 * step, origin + (iz + 1) as f64 * step], [origin + ix as f64 * step, origin + (iy + 1) as f64 * step, origin + (iz + 1) as f64 * step], ]; let edge_table = mc_edge_table(cube_idx); let tri_table = mc_tri_table(cube_idx); let mut verts = [[0.0f64; 3]; 12]; for e in 0..12 { if edge_table & (1 << e) != 0 { let (a, b) = MC_EDGES[e]; let va = vals[a]; let vb = vals[b]; let t = if (va - vb).abs() > 1e-10 { va / (va - vb) } else { 0.5 }; for d in 0..3 { verts[e][d] = pos[a][d] + t * (pos[b][d] - pos[a][d]); } } } let mut i = 0; while i < 16 && tri_table[i] != -1 { let a = tri_table[i] as usize; let b = tri_table[i + 1] as usize; let c = tri_table[i + 2] as usize; let va = verts[a].map(|x| x as f32); let vb = verts[b].map(|x| x as f32); let vc = verts[c].map(|x| x as f32); let e1 = [vb[0] - va[0], vb[1] - va[1], vb[2] - va[2]]; let e2 = [vc[0] - va[0], vc[1] - va[1], vc[2] - va[2]]; let mut norm = [ e1[1] * e2[2] - e1[2] * e2[1], e1[2] * e2[0] - e1[0] * e2[2], e1[0] * e2[1] - e1[1] * e2[0], ]; let len = (norm[0] * norm[0] + norm[1] * norm[1] + norm[2] * norm[2]).sqrt(); if len > 1e-10 { norm[0] /= len; norm[1] /= len; norm[2] /= len; } tris.push(Triangle { v: [va, vb, vc], n: norm }); i += 3; } } } } tris } const MC_EDGES: [(usize, usize); 12] = [ (0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7), ]; fn mc_edge_table(idx: u8) -> u16 { MC_EDGE_TABLE[idx as usize] } fn mc_tri_table(idx: u8) -> [i8; 16] { MC_TRI_TABLE[idx as usize] } include!("mc_tables.rs"); fn write_stl_binary(path: &std::path::Path, tris: &[Triangle]) -> Result<(), String> { use std::io::Write; let mut buf = Vec::with_capacity(84 + tris.len() * 50); buf.extend_from_slice(&[0u8; 80]); buf.extend_from_slice(&(tris.len() as u32).to_le_bytes()); for tri in tris { for &c in &tri.n { buf.extend_from_slice(&c.to_le_bytes()); } for v in &tri.v { for &c in v { buf.extend_from_slice(&c.to_le_bytes()); } } buf.extend_from_slice(&[0u8; 2]); } let mut file = std::fs::File::create(path).map_err(|e| e.to_string())?; file.write_all(&buf).map_err(|e| e.to_string()) } fn write_3mf(path: &std::path::Path, tris: &[Triangle]) -> Result<(), String> { use std::io::Write; let mut verts: Vec<[f32; 3]> = Vec::new(); let mut indices: Vec<[usize; 3]> = Vec::new(); let mut vert_map: std::collections::HashMap<[u32; 3], usize> = std::collections::HashMap::new(); for tri in tris { let mut face = [0usize; 3]; for (i, v) in tri.v.iter().enumerate() { let key = [v[0].to_bits(), v[1].to_bits(), v[2].to_bits()]; let idx = vert_map.entry(key).or_insert_with(|| { let n = verts.len(); verts.push(*v); n }); face[i] = *idx; } indices.push(face); } let mut model_xml = String::from( "\n\ \n\ \n\n" ); for v in &verts { model_xml += &format!("\n", v[0], v[1], v[2]); } model_xml += "\n\n"; for f in &indices { model_xml += &format!("\n", f[0], f[1], f[2]); } model_xml += "\n\n\ \n"; let content_types = "\n\ \n\ \n\ \n\ "; let rels = "\n\ \n\ \n\ "; let file = std::fs::File::create(path).map_err(|e| e.to_string())?; let mut zip = zip::ZipWriter::new(file); let options = zip::write::SimpleFileOptions::default() .compression_method(zip::CompressionMethod::Deflated); zip.start_file("[Content_Types].xml", options).map_err(|e| e.to_string())?; zip.write_all(content_types.as_bytes()).map_err(|e| e.to_string())?; zip.start_file("_rels/.rels", options).map_err(|e| e.to_string())?; zip.write_all(rels.as_bytes()).map_err(|e| e.to_string())?; zip.start_file("3D/3dmodel.model", options).map_err(|e| e.to_string())?; zip.write_all(model_xml.as_bytes()).map_err(|e| e.to_string())?; zip.finish().map_err(|e| e.to_string())?; Ok(()) } fn setup_native_menu() { use muda::*; use muda::accelerator::{Accelerator, Modifiers, Code}; let m = |mods, key| Some(Accelerator::new(Some(mods), key)); let cmd = Modifiers::SUPER; let cmd_shift = Modifiers::SUPER | Modifiers::SHIFT; let menu = Menu::new(); let app_menu = Submenu::with_items("Cord", true, &[ &PredefinedMenuItem::about(None, None), &PredefinedMenuItem::separator(), &PredefinedMenuItem::hide(None), &PredefinedMenuItem::hide_others(None), &PredefinedMenuItem::show_all(None), &PredefinedMenuItem::separator(), &PredefinedMenuItem::quit(None), ]).unwrap(); let file_menu = Submenu::with_items("File", true, &[ &MenuItem::with_id("new", "New", true, m(cmd, Code::KeyN)), &MenuItem::with_id("open", "Open\u{2026}", true, m(cmd, Code::KeyO)), &PredefinedMenuItem::separator(), &MenuItem::with_id("save", "Save", true, m(cmd, Code::KeyS)), &MenuItem::with_id("save_as", "Save As\u{2026}", true, m(cmd_shift, Code::KeyS)), &PredefinedMenuItem::separator(), &MenuItem::with_id("save_sources", "Save Sources\u{2026}", true, None), &MenuItem::with_id("export_single", "Export Source", true, m(cmd, Code::KeyE)), &MenuItem::with_id("export_individual", "Export Sources Individually", true, m(cmd_shift, Code::KeyE)), &PredefinedMenuItem::separator(), &MenuItem::with_id("export_stl", "Export STL\u{2026}", true, None), &MenuItem::with_id("export_3mf", "Export 3MF\u{2026}", true, None), &MenuItem::with_id("export_step", "Export STEP\u{2026}", true, None), &MenuItem::with_id("export_scad", "Export OpenSCAD\u{2026}", true, None), &PredefinedMenuItem::separator(), &MenuItem::with_id("decompose", "Decompose Mesh to Cordial", true, m(cmd_shift, Code::KeyD)), ]).unwrap(); let edit_menu = Submenu::with_items("Edit", true, &[ &MenuItem::with_id("undo", "Undo", true, m(cmd, Code::KeyZ)), &MenuItem::with_id("redo", "Redo", true, m(cmd_shift, Code::KeyZ)), &PredefinedMenuItem::separator(), &PredefinedMenuItem::cut(None), &PredefinedMenuItem::copy(None), &PredefinedMenuItem::paste(None), &PredefinedMenuItem::separator(), &PredefinedMenuItem::select_all(None), ]).unwrap(); let render_menu = Submenu::with_items("Render", true, &[ &MenuItem::with_id("render_objects", "3D Objects and Vars", true, m(cmd, Code::Digit3)), &MenuItem::with_id("render_plots", "Functions/Expressions", true, m(cmd, Code::Digit2)), &PredefinedMenuItem::separator(), &MenuItem::with_id("render_all", "All", true, m(cmd, Code::KeyR)), ]).unwrap(); menu.append(&app_menu).unwrap(); menu.append(&file_menu).unwrap(); menu.append(&edit_menu).unwrap(); menu.append(&render_menu).unwrap(); #[cfg(target_os = "macos")] menu.init_for_nsapp(); 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); } fn sim_reparse(src: &str) -> cord_trig::TrigGraph { use cord_expr::{classify_from, expr_to_sdf}; let src = src.trim(); let scene = parse_expr_scene(src).expect("parse failed"); let mut graph = scene.graph; let mut sdf_roots: Vec = Vec::new(); let render_objects: Vec<_> = if scene.cast_all { scene.all_vars.clone() } else if !scene.casts.is_empty() { scene.casts.clone() } else { Vec::new() }; for &(_, id) in &render_objects { sdf_roots.push(id); } let mut plot_nodes: Vec = if scene.plot_all { let mut nodes: Vec<_> = scene.all_vars.iter().map(|(_, id)| *id).collect(); nodes.extend(&scene.bare_exprs); nodes } else { Vec::new() }; plot_nodes.extend(&scene.plots); for &node_id in &plot_nodes { let info = classify_from(&graph, node_id); let sdf = expr_to_sdf(&mut graph, node_id, info.dimensions, 0.05, 10.0); sdf_roots.push(sdf); } if sdf_roots.len() >= 2 { let mut root = sdf_roots[0]; for &node in &sdf_roots[1..] { root = graph.push(cord_trig::TrigOp::Min(root, node)); } graph.set_output(root); } else if sdf_roots.len() == 1 { graph.set_output(sdf_roots[0]); } else { let empty = graph.push(cord_trig::TrigOp::Const(1e10)); graph.set_output(empty); } graph } #[test] fn open_file_produces_different_wgsl() { let default_src = "let s = sphere(3)\ncast()"; let shell_src = std::fs::read_to_string( concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/shell.crd") ).expect("can't read shell.crd"); let bolt_src = std::fs::read_to_string( concat!(env!("CARGO_MANIFEST_DIR"), "/../../examples/bolt.crd") ).expect("can't read bolt.crd"); let default_graph = sim_reparse(default_src); let shell_graph = sim_reparse(&shell_src); let bolt_graph = sim_reparse(&bolt_src); let default_wgsl = cord_shader::generate_wgsl_from_trig(&default_graph); let shell_wgsl = cord_shader::generate_wgsl_from_trig(&shell_graph); let bolt_wgsl = cord_shader::generate_wgsl_from_trig(&bolt_graph); assert_ne!(default_wgsl, shell_wgsl, "shell.crd should produce different WGSL than default"); assert_ne!(default_wgsl, bolt_wgsl, "bolt.crd should produce different WGSL than default"); assert_ne!(shell_wgsl, bolt_wgsl, "shell and bolt should produce different WGSL"); assert!(shell_graph.nodes.len() > 10, "shell should have a nontrivial graph"); assert!(bolt_graph.nodes.len() > 10, "bolt should have a nontrivial graph"); } }