2367 lines
84 KiB
Rust
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");
|
|
}
|
|
}
|