Skip to content

Commit 99d8330

Browse files
Merge pull request #14 from particlesector/feat/lang-modules
feat: user-defined modules, independent root rendering, and session persistence
2 parents 8d81359 + ef3274c commit 99d8330

23 files changed

Lines changed: 677 additions & 48 deletions

src/app/Application.cpp

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,20 @@ void Application::run() {
107107
initImGui();
108108

109109
m_camera.init(m_config.cameraDistance);
110+
m_camera.setState(m_config.cameraYaw, m_config.cameraPitch,
111+
m_config.cameraDistance,
112+
{m_config.cameraTargetX, m_config.cameraTargetY, m_config.cameraTargetZ});
113+
m_meshBuilder.setWarnOverlappingRoots(m_config.warnOverlappingRoots);
114+
115+
// Restore last-opened file when none was provided on the command line
116+
if (m_state.scadPath.empty() && !m_config.lastFilePath.empty()) {
117+
std::filesystem::path lastPath(m_config.lastFilePath);
118+
std::error_code ec;
119+
if (std::filesystem::exists(lastPath, ec)) {
120+
m_state.scadPath = lastPath;
121+
m_firstMesh = false; // use restored camera, don't auto-fit
122+
}
123+
}
110124

111125
if (!m_state.scadPath.empty()) {
112126
auto ext = m_state.scadPath.extension().string();
@@ -188,6 +202,26 @@ void Application::run() {
188202
}
189203

190204
vkDeviceWaitIdle(m_ctx.device());
205+
206+
// Persist window state
207+
int ww = 0, wh = 0;
208+
glfwGetWindowSize(m_window, &ww, &wh);
209+
if (ww > 0 && wh > 0) {
210+
m_config.windowWidth = ww;
211+
m_config.windowHeight = wh;
212+
}
213+
214+
// Persist camera state
215+
m_config.cameraDistance = m_camera.distance();
216+
m_config.cameraYaw = m_camera.yaw();
217+
m_config.cameraPitch = m_camera.pitch();
218+
m_config.cameraTargetX = m_camera.target().x;
219+
m_config.cameraTargetY = m_camera.target().y;
220+
m_config.cameraTargetZ = m_camera.target().z;
221+
222+
// Persist last opened file
223+
m_config.lastFilePath = m_state.scadPath.string();
224+
191225
m_config.save(Config::defaultPath());
192226
}
193227

@@ -387,6 +421,9 @@ void Application::drawMenuBar() {
387421
if (ImGui::MenuItem("Presentation Mode", "P", m_presentationMode))
388422
m_presentationMode = !m_presentationMode;
389423
ImGui::Separator();
424+
if (ImGui::MenuItem("Preferences..."))
425+
m_showPrefs = true;
426+
ImGui::Separator();
390427

391428
if (ImGui::BeginMenu("Rendering")) {
392429
bool isSolid = (m_renderMode == render::RenderMode::Solid);
@@ -431,6 +468,44 @@ void Application::drawMenuBar() {
431468
ImGui::EndMenuBar();
432469
}
433470

471+
// ---------------------------------------------------------------------------
472+
// Preferences popup
473+
// ---------------------------------------------------------------------------
474+
void Application::drawPrefsPopup() {
475+
if (m_showPrefs) {
476+
ImGui::OpenPopup("Preferences");
477+
m_showPrefs = false;
478+
}
479+
480+
ImGui::SetNextWindowSize({360, 0}, ImGuiCond_Always);
481+
if (ImGui::BeginPopupModal("Preferences", nullptr,
482+
ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove)) {
483+
484+
// ── Analysis ─────────────────────────────────────────────────────
485+
ImGui::SeparatorText("Analysis");
486+
487+
bool prev = m_config.warnOverlappingRoots;
488+
ImGui::Checkbox("Warn on overlapping root objects", &m_config.warnOverlappingRoots);
489+
if (ImGui::IsItemHovered())
490+
ImGui::SetTooltip(
491+
"After each build, test whether any top-level objects\n"
492+
"overlap and warn if so. Has a small per-pair cost;\n"
493+
"disable for large scenes with many root objects.");
494+
if (m_config.warnOverlappingRoots != prev) {
495+
m_meshBuilder.setWarnOverlappingRoots(m_config.warnOverlappingRoots);
496+
if (!m_state.scadPath.empty())
497+
m_meshBuilder.requestBuild(m_state.scadPath);
498+
}
499+
500+
ImGui::Spacing();
501+
ImGui::Separator();
502+
if (ImGui::Button("Close", {80, 0}))
503+
ImGui::CloseCurrentPopup();
504+
505+
ImGui::EndPopup();
506+
}
507+
}
508+
434509
// ---------------------------------------------------------------------------
435510
// ImGui
436511
// ---------------------------------------------------------------------------
@@ -565,6 +640,8 @@ void Application::drawImGui() {
565640
ImGui::EndPopup();
566641
}
567642

643+
drawPrefsPopup();
644+
568645
if (m_showAbout)
569646
ImGui::OpenPopup("About ChiselCAD");
570647

src/app/Application.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ class Application {
4343
// ImGui drawing
4444
void drawMenuBar();
4545
void drawImGui();
46+
void drawPrefsPopup();
4647

4748
// Camera / file helpers
4849
void fitToView();
@@ -99,6 +100,7 @@ class Application {
99100
// UI state
100101
bool m_showChiselPanel = true;
101102
bool m_showAbout = false;
103+
bool m_showPrefs = false;
102104
float m_fontScale = 1.0f;
103105

104106
// Export error shown in a modal

src/app/Config.cpp

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,17 @@ Config Config::load(const std::filesystem::path& path) {
3232
if (j.contains("windowWidth")) cfg.windowWidth = j["windowWidth"];
3333
if (j.contains("windowHeight")) cfg.windowHeight = j["windowHeight"];
3434
if (j.contains("cameraDistance")) cfg.cameraDistance = j["cameraDistance"];
35+
if (j.contains("cameraYaw")) cfg.cameraYaw = j["cameraYaw"].get<float>();
36+
if (j.contains("cameraPitch")) cfg.cameraPitch = j["cameraPitch"].get<float>();
37+
if (j.contains("cameraTargetX")) cfg.cameraTargetX = j["cameraTargetX"].get<float>();
38+
if (j.contains("cameraTargetY")) cfg.cameraTargetY = j["cameraTargetY"].get<float>();
39+
if (j.contains("cameraTargetZ")) cfg.cameraTargetZ = j["cameraTargetZ"].get<float>();
40+
if (j.contains("lastFilePath")) cfg.lastFilePath = j["lastFilePath"];
3541
if (j.contains("globalFn")) cfg.globalFn = j["globalFn"];
3642
if (j.contains("globalFs")) cfg.globalFs = j["globalFs"];
3743
if (j.contains("globalFa")) cfg.globalFa = j["globalFa"];
38-
if (j.contains("fontSize")) cfg.fontSize = j["fontSize"];
44+
if (j.contains("fontSize")) cfg.fontSize = j["fontSize"];
45+
if (j.contains("warnOverlappingRoots")) cfg.warnOverlappingRoots = j["warnOverlappingRoots"];
3946
} catch (const std::exception& e) {
4047
spdlog::warn("Config load failed: {}", e.what());
4148
}
@@ -50,10 +57,17 @@ void Config::save(const std::filesystem::path& path) const {
5057
j["windowWidth"] = windowWidth;
5158
j["windowHeight"] = windowHeight;
5259
j["cameraDistance"] = cameraDistance;
60+
j["cameraYaw"] = cameraYaw;
61+
j["cameraPitch"] = cameraPitch;
62+
j["cameraTargetX"] = cameraTargetX;
63+
j["cameraTargetY"] = cameraTargetY;
64+
j["cameraTargetZ"] = cameraTargetZ;
65+
j["lastFilePath"] = lastFilePath;
5366
j["globalFn"] = globalFn;
5467
j["globalFs"] = globalFs;
5568
j["globalFa"] = globalFa;
56-
j["fontSize"] = fontSize;
69+
j["fontSize"] = fontSize;
70+
j["warnOverlappingRoots"] = warnOverlappingRoots;
5771
std::ofstream f(path);
5872
f << j.dump(2);
5973
} catch (const std::exception& e) {

src/app/Config.h

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,21 @@ struct Config {
1212
int windowWidth = 1280;
1313
int windowHeight = 800;
1414
float cameraDistance = 50.0f;
15+
float cameraYaw = 0.0f;
16+
float cameraPitch = 0.4f;
17+
float cameraTargetX = 0.0f;
18+
float cameraTargetY = 0.0f;
19+
float cameraTargetZ = 0.0f;
20+
std::string lastFilePath;
21+
1522
double globalFn = 0.0;
1623
double globalFs = 2.0;
1724
double globalFa = 12.0;
1825
int fontSize = 1; // 0=small(0.85×), 1=normal(1.0×), 2=large(1.3×)
1926

27+
// Analysis preferences
28+
bool warnOverlappingRoots = false;
29+
2030
static Config load(const std::filesystem::path& path);
2131
void save(const std::filesystem::path& path) const;
2232

src/app/MeshBuilder.cpp

Lines changed: 70 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,9 +170,9 @@ void MeshBuilder::buildOne(std::filesystem::path path, int gen) {
170170
csg::MeshCache cache;
171171
csg::MeshEvaluator meshEval(cache);
172172
meshEval.useManifoldSphere = m_useManifoldSphere.load();
173-
manifold::Manifold manifoldMesh;
173+
std::vector<manifold::Manifold> rootMeshes;
174174
try {
175-
manifoldMesh = meshEval.evaluate(scene);
175+
rootMeshes = meshEval.evaluate(scene);
176176
} catch (const std::exception& e) {
177177
result->errorMsg = std::string("Mesh error: ") + e.what();
178178
storeError(std::move(result));
@@ -184,18 +184,78 @@ void MeshBuilder::buildOne(std::filesystem::path path, int gen) {
184184
// ---- Phase: Converting to vertex buffers ----
185185
m_phase = BuildPhase::Converting;
186186

187-
// Capture mesh properties before flat-shading (volume is exact, counts comparable)
188-
{
189-
result->volume = manifoldMesh.Volume();
190-
result->surfaceArea = manifoldMesh.SurfaceArea();
187+
// Each root is converted independently and appended — this keeps objects
188+
// that are spatially inside other objects visible (no boolean union across roots).
189+
std::vector<uint32_t> rootVertStart; // per-root start index into result->verts
190+
rootVertStart.reserve(rootMeshes.size());
191+
192+
for (const auto& m : rootMeshes) {
193+
rootVertStart.push_back(static_cast<uint32_t>(result->verts.size()));
194+
195+
result->volume += m.Volume();
196+
result->surfaceArea += m.SurfaceArea();
191197

192-
auto rawMesh = manifoldMesh.GetMeshGL();
193-
result->triCount = static_cast<uint32_t>(rawMesh.triVerts.size() / 3);
194-
result->vertCount = static_cast<uint32_t>(
198+
auto rawMesh = m.GetMeshGL();
199+
result->triCount += static_cast<uint32_t>(rawMesh.triVerts.size() / 3);
200+
result->vertCount += static_cast<uint32_t>(
195201
rawMesh.numProp > 0 ? rawMesh.vertProperties.size() / rawMesh.numProp : 0);
202+
203+
std::vector<render::Vertex> verts;
204+
std::vector<uint32_t> indices;
205+
manifoldToMesh(m, verts, indices);
206+
207+
// Offset indices by the current vertex count before appending
208+
const auto base = static_cast<uint32_t>(result->verts.size());
209+
for (auto& idx : indices) idx += base;
210+
211+
result->verts.insert(result->verts.end(), verts.begin(), verts.end());
212+
result->indices.insert(result->indices.end(), indices.begin(), indices.end());
196213
}
197214

198-
manifoldToMesh(manifoldMesh, result->verts, result->indices);
215+
// ---- Optional: pairwise overlap detection ----
216+
if (m_warnOverlappingRoots.load() && rootMeshes.size() > 1) {
217+
// Compute per-root AABB from the already-converted vertex data
218+
const auto totalVerts = static_cast<uint32_t>(result->verts.size());
219+
auto rootAABB = [&](std::size_t ri) -> std::pair<glm::vec3, glm::vec3> {
220+
uint32_t start = rootVertStart[ri];
221+
uint32_t end = (ri + 1 < rootVertStart.size())
222+
? rootVertStart[ri + 1] : totalVerts;
223+
glm::vec3 bmin{ 1e30f, 1e30f, 1e30f};
224+
glm::vec3 bmax{-1e30f, -1e30f, -1e30f};
225+
for (uint32_t vi = start; vi < end; ++vi) {
226+
bmin = glm::min(bmin, result->verts[vi].pos);
227+
bmax = glm::max(bmax, result->verts[vi].pos);
228+
}
229+
return {bmin, bmax};
230+
};
231+
232+
auto aabbOverlap = [](glm::vec3 mn1, glm::vec3 mx1,
233+
glm::vec3 mn2, glm::vec3 mx2) {
234+
return (mn1.x <= mx2.x && mx1.x >= mn2.x) &&
235+
(mn1.y <= mx2.y && mx1.y >= mn2.y) &&
236+
(mn1.z <= mx2.z && mx1.z >= mn2.z);
237+
};
238+
239+
for (std::size_t i = 0; i < rootMeshes.size(); ++i) {
240+
if (gen != m_currentGen.load()) return; // newer build queued — abort
241+
auto [mn1, mx1] = rootAABB(i);
242+
for (std::size_t j = i + 1; j < rootMeshes.size(); ++j) {
243+
auto [mn2, mx2] = rootAABB(j);
244+
if (!aabbOverlap(mn1, mx1, mn2, mx2)) continue;
245+
246+
// AABBs overlap — run exact Manifold intersection
247+
manifold::Manifold sect = rootMeshes[i] ^ rootMeshes[j];
248+
if (std::abs(sect.Volume()) > 1e-6) {
249+
lang::Diagnostic warn;
250+
warn.level = lang::DiagLevel::Warning;
251+
warn.message = "Objects " + std::to_string(i + 1) +
252+
" and " + std::to_string(j + 1) +
253+
" overlap — wrap in union() or difference() if intentional";
254+
result->diags.push_back(std::move(warn));
255+
}
256+
}
257+
}
258+
}
199259
result->elapsedMs = elapsedMs();
200260

201261
m_elapsedMs = result->elapsedMs;

src/app/MeshBuilder.h

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ class MeshBuilder {
5454
// discarded when poll() is next called.
5555
void requestBuild(std::filesystem::path path);
5656

57-
void setUseManifoldSphere(bool v) noexcept { m_useManifoldSphere.store(v); }
57+
void setUseManifoldSphere(bool v) noexcept { m_useManifoldSphere.store(v); }
58+
void setWarnOverlappingRoots(bool v) noexcept { m_warnOverlappingRoots.store(v); }
5859

5960
// Call once per frame from the main (Vulkan) thread.
6061
// Returns a finished BuildResult when one is ready, nullptr otherwise.
@@ -86,6 +87,7 @@ class MeshBuilder {
8687
// Incremented by requestBuild(); read by poll() to detect stale results.
8788
std::atomic<int> m_currentGen{0};
8889
std::atomic<bool> m_useManifoldSphere{false};
90+
std::atomic<bool> m_warnOverlappingRoots{false};
8991

9092
// Readable from main thread for UI without locks.
9193
std::atomic<BuildPhase> m_phase{BuildPhase::Idle};

src/csg/CsgEvaluator.cpp

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@ CsgScene CsgEvaluator::evaluate(const ParseResult& result) {
2020
CsgScene CsgEvaluator::evaluate(const ParseResult& result, Interpreter& interp) {
2121
m_interp = &interp;
2222

23+
// Index module definitions by name for O(1) lookup during calls
24+
m_moduleDefs.clear();
25+
for (const auto& def : result.moduleDefs)
26+
m_moduleDefs[def.name] = &def;
27+
2328
CsgScene scene;
2429
scene.globalFn = result.globalFn;
2530
scene.globalFs = result.globalFs;
@@ -32,6 +37,7 @@ CsgScene CsgEvaluator::evaluate(const ParseResult& result, Interpreter& interp)
3237
}
3338

3439
m_interp = nullptr;
40+
m_moduleDefs.clear();
3541
return scene;
3642
}
3743

@@ -51,6 +57,8 @@ CsgNodePtr CsgEvaluator::evalNode(const AstNode& node, const glm::mat4& xform) {
5157
return evalIf(n, xform);
5258
else if constexpr (std::is_same_v<T, ForNode>)
5359
return evalFor(n, xform);
60+
else if constexpr (std::is_same_v<T, ModuleCallNode>)
61+
return evalModuleCall(n, xform);
5462
return nullptr;
5563
}, node);
5664
}
@@ -250,4 +258,65 @@ CsgNodePtr CsgEvaluator::evalFor(const ForNode& node, const glm::mat4& xform) {
250258
return makeBoolean(std::move(u));
251259
}
252260

261+
// ---------------------------------------------------------------------------
262+
// Module call — bind args, evaluate body, restore environment
263+
// ---------------------------------------------------------------------------
264+
CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::mat4& xform) {
265+
auto it = m_moduleDefs.find(call.name);
266+
if (it == m_moduleDefs.end()) return nullptr; // undefined module
267+
268+
const ModuleDef& def = *it->second;
269+
270+
// Snapshot the interpreter env so we can restore it after the call
271+
auto savedEnv = m_interp->snapshotEnv();
272+
273+
// Bind positional and named arguments to module parameters
274+
std::size_t posIdx = 0;
275+
for (const auto& arg : call.args) {
276+
if (arg.name.empty()) {
277+
// Positional: bind to parameter at posIdx
278+
if (posIdx < def.params.size())
279+
m_interp->setVar(def.params[posIdx].name,
280+
Value::fromNumber(m_interp->evalNumber(*arg.value)));
281+
++posIdx;
282+
} else {
283+
// Named: bind to the matching parameter
284+
m_interp->setVar(arg.name,
285+
Value::fromNumber(m_interp->evalNumber(*arg.value)));
286+
}
287+
}
288+
289+
// Fill in defaults for parameters that were not supplied
290+
for (std::size_t i = 0; i < def.params.size(); ++i) {
291+
const auto& param = def.params[i];
292+
// Skip params already bound by positional or named args
293+
bool alreadyBound = (i < posIdx);
294+
if (!alreadyBound) {
295+
for (const auto& arg : call.args)
296+
if (arg.name == param.name) { alreadyBound = true; break; }
297+
}
298+
if (!alreadyBound && param.defaultVal)
299+
m_interp->setVar(param.name,
300+
Value::fromNumber(m_interp->evalNumber(*param.defaultVal)));
301+
}
302+
303+
// Evaluate the module body and collect geometry
304+
std::vector<CsgNodePtr> all;
305+
for (const auto& child : def.body) {
306+
if (auto c = evalNode(*child, xform))
307+
all.push_back(std::move(c));
308+
}
309+
310+
// Restore the caller's environment
311+
m_interp->restoreEnv(std::move(savedEnv));
312+
313+
if (all.empty()) return nullptr;
314+
if (all.size() == 1) return all[0];
315+
316+
CsgBoolean u;
317+
u.op = CsgBoolean::Op::Union;
318+
u.children = std::move(all);
319+
return makeBoolean(std::move(u));
320+
}
321+
253322
} // namespace chisel::csg

0 commit comments

Comments
 (0)