Cord/crates/cord-gui/src/app.rs

2367 lines
84 KiB
Rust

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<ExprInfo>,
error: Option<String>,
viewport: SdfViewport,
mouse_pos: Point,
status: Option<String>,
md_items: Vec<markdown::Item>,
undo_stack: Vec<String>,
redo_stack: Vec<String>,
current_path: Option<PathBuf>,
dirty: bool,
recents: Vec<PathBuf>,
scene_objects: Vec<String>,
selected_object: Option<String>,
needs_cast: bool,
needs_plot: bool,
context_menu_pos: Option<Point>,
menu_ready: bool,
cursor_line: usize,
line_eval_text: Option<String>,
mesh_path: Option<PathBuf>,
}
#[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<PathBuf> {
let p = recents_path();
std::fs::read_to_string(&p)
.ok()
.and_then(|s| serde_json::from_str::<Vec<String>>(&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<String> {
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<cord_trig::ir::NodeId> = 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<cord_trig::ir::NodeId> = 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::<Vec<_>>()
.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<cord_trig::TrigGraph> {
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<Message> {
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::<CordHighlighter>(CordHighlighterSettings, format_token)
.padding(10)
.size(14)
.height(editor_height);
let editor_el: Element<Message> = 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<Element<Message>> = 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<Element<Message>> = vec![
editor_el,
];
// Recents panel
if has_recents {
let mut recents_col: Vec<Element<Message>> = 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<Message> =
Shader::new(&self.viewport).width(Fill).height(Fill).into();
// Object selector bar
let obj_bar: Element<Message> = if !self.scene_objects.is_empty() {
let mut obj_buttons: Vec<Element<Message>> = 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<Element<Message>> = 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>>) -> 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<Element<'a, Message>>, 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<String> {
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<f64> = 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<String, String> {
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<MeshImport, String> {
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<usize> {
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<f32>,
rotate_y: Option<f32>,
rotate_z: Option<f32>,
scale: Option<f32>,
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::<f32>() else { break };
let Ok(ty) = parts[1].trim().parse::<f32>() else { break };
let Ok(tz) = parts[2].trim().parse::<f32>() 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::<f32>() else { break };
stack.rotate_x = Some(stack.rotate_x.unwrap_or(0.0) + a);
}
"rotate_y" | "ry" => {
let Ok(a) = args.trim().parse::<f32>() else { break };
stack.rotate_y = Some(stack.rotate_y.unwrap_or(0.0) + a);
}
"rotate_z" | "rz" => {
let Ok(a) = args.trim().parse::<f32>() else { break };
stack.rotate_z = Some(stack.rotate_z.unwrap_or(0.0) + a);
}
"scale" => {
let Ok(s) = args.trim().parse::<f32>() 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<Triangle> {
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(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<model unit=\"millimeter\" xmlns=\"http://schemas.microsoft.com/3dmanufacturing/core/2015/02\">\n\
<resources><object id=\"1\" type=\"model\"><mesh>\n<vertices>\n"
);
for v in &verts {
model_xml += &format!("<vertex x=\"{}\" y=\"{}\" z=\"{}\" />\n", v[0], v[1], v[2]);
}
model_xml += "</vertices>\n<triangles>\n";
for f in &indices {
model_xml += &format!("<triangle v1=\"{}\" v2=\"{}\" v3=\"{}\" />\n", f[0], f[1], f[2]);
}
model_xml += "</triangles>\n</mesh></object></resources>\n\
<build><item objectid=\"1\" /></build>\n</model>";
let content_types = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\">\n\
<Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\" />\n\
<Default Extension=\"model\" ContentType=\"application/vnd.ms-package.3dmanufacturing-3dmodel+xml\" />\n\
</Types>";
let rels = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\">\n\
<Relationship Target=\"/3D/3dmodel.model\" Id=\"rel0\" \
Type=\"http://schemas.microsoft.com/3dmanufacturing/2013/01/3dmodel\" />\n\
</Relationships>";
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<cord_trig::ir::NodeId> = 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<cord_trig::ir::NodeId> = 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");
}
}