diff --git a/CMakeLists.txt b/CMakeLists.txt index 9513010..5168e69 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -162,10 +162,16 @@ target_compile_definitions(chiselcad PRIVATE ) # ============================================================ -# Tests (Catch2) +# Tests (Catch2 — built from source to match the active toolchain) # ============================================================ enable_testing() -find_package(Catch2 CONFIG REQUIRED) +include(FetchContent) +FetchContent_Declare( + Catch2 + GIT_REPOSITORY https://github.com/catchorg/Catch2.git + GIT_TAG v3.7.1 +) +FetchContent_MakeAvailable(Catch2) set(CHISELCAD_TEST_SOURCES tests/test_main.cpp @@ -197,6 +203,7 @@ else() target_compile_options(chiselcad_tests PRIVATE -Wall -Wextra -Wpedantic -Werror) endif() +list(APPEND CMAKE_MODULE_PATH ${catch2_SOURCE_DIR}/extras) include(Catch) catch_discover_tests(chiselcad_tests) diff --git a/docs/roadmap.md b/docs/roadmap.md index 6f94069..37037b2 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,48 +1,48 @@ # ChiselCAD — Roadmap -## v1 — Core CSG (current focus) +## v1 — Core CSG ✓ -- [ ] CMake + vcpkg build scaffold -- [ ] Vulkan context, swapchain, ImGui integration -- [ ] Lexer + recursive descent parser (core subset) -- [ ] CSG tree evaluator (AST → CsgNode tree) -- [ ] Primitive tessellator (cube, sphere, cylinder) -- [ ] Manifold boolean evaluation (union, difference, intersection) -- [ ] Async eval pipeline with std::stop_token cancellation -- [ ] Preview render mode (color-coded primitives) -- [ ] Result render mode (evaluated mesh, PBR shading) -- [ ] GPU mesh double-buffer swap -- [ ] Arcball orbit camera -- [ ] File watcher + VS Code external editor integration -- [ ] Diagnostics panel with clickable jump-to-line -- [ ] Binary STL export -- [ ] Embedded ImGuiColorTextEdit editor -- [ ] Mesh cache (LRU, hash-keyed by CSG subtree) +- [x] CMake + vcpkg build scaffold +- [x] Vulkan context, swapchain, ImGui integration +- [x] Lexer + recursive descent parser (core subset) +- [x] CSG tree evaluator (AST → CsgNode tree) +- [x] Primitive tessellator (cube, sphere, cylinder) +- [x] Manifold boolean evaluation (union, difference, intersection) +- [x] Async eval pipeline with std::stop_token cancellation +- [x] Preview render mode (color-coded primitives) +- [x] Result render mode (evaluated mesh, Blinn-Phong shading) +- [x] GPU mesh double-buffer swap +- [x] Arcball orbit camera +- [x] File watcher + VS Code external editor integration +- [x] Diagnostics panel with clickable jump-to-line +- [x] Binary STL export +- [x] Embedded ImGuiColorTextEdit editor +- [x] Mesh cache (LRU, hash-keyed by CSG subtree) -## v2 — Language Expansion +## v2 — Language Expansion ✓ -- [ ] Full OpenSCAD language: `for`, `if`, `let`, variables, math functions -- [ ] User-defined modules and function literals -- [ ] 2D primitives: `square`, `circle`, `polygon` -- [ ] Extrusion: `linear_extrude`, `rotate_extrude` -- [ ] `hull()` and `minkowski()` +- [x] Full OpenSCAD language: `for`, `if`, `let`, variables, math functions +- [x] User-defined modules and function literals +- [x] 2D primitives: `square`, `circle`, `polygon` +- [x] Extrusion: `linear_extrude`, `rotate_extrude` +- [x] `hull()` and `minkowski()` ## v2.5 — OpenSCAD Language Completeness -### Tier A — High Impact (used constantly) -- [ ] List indexing `v[i]` -- [ ] Ternary operator `condition ? a : b` -- [ ] User-defined functions `function f(x) = expr;` -- [ ] `let` expression `let (x=10) child` -- [ ] `undef` literal -- [ ] `concat()` built-in +### Tier A — High Impact (used constantly) ✓ +- [x] List indexing `v[i]` +- [x] Ternary operator `condition ? a : b` +- [x] User-defined functions `function f(x) = expr;` +- [x] `let` expression `let (x=10) child` +- [x] `undef` literal +- [x] `concat()` built-in -### Tier B — Math & String Completeness -- [ ] Inverse trig: `asin()`, `acos()`, `atan()`, `atan2()` -- [ ] Vector math: `norm()`, `cross()`, `sign()` -- [ ] `rands()`, `lookup()` -- [ ] String literals + `str()`, `chr()`, `ord()` -- [ ] `len()` on strings +### Tier B — Math & String Completeness ✓ +- [x] Inverse trig: `asin()`, `acos()`, `atan()`, `atan2()` +- [x] Vector math: `norm()`, `cross()`, `sign()` +- [x] `rands()`, `lookup()` +- [x] String literals + `str()`, `chr()`, `ord()` +- [x] `len()` on strings ### Tier C — Module System Completeness - [ ] `children()` / `$children` diff --git a/src/app/Application.cpp b/src/app/Application.cpp index 3b75c0d..d12cef1 100644 --- a/src/app/Application.cpp +++ b/src/app/Application.cpp @@ -274,18 +274,18 @@ void Application::initImGui() { ImGui_ImplGlfw_InitForVulkan(m_window, true); ImGui_ImplVulkan_InitInfo ii{}; - ii.Instance = m_ctx.instance(); - ii.PhysicalDevice = m_ctx.physDevice(); - ii.Device = m_ctx.device(); - ii.QueueFamily = m_ctx.graphicsFamily(); - ii.Queue = m_ctx.graphicsQueue(); - ii.DescriptorPool = m_imguiPool; - ii.RenderPass = m_swapchain.renderPass(); - ii.MinImageCount = 2; - ii.ImageCount = m_swapchain.imageCount(); - ii.MSAASamples = VK_SAMPLE_COUNT_1_BIT; + ii.Instance = m_ctx.instance(); + ii.PhysicalDevice = m_ctx.physDevice(); + ii.Device = m_ctx.device(); + ii.QueueFamily = m_ctx.graphicsFamily(); + ii.Queue = m_ctx.graphicsQueue(); + ii.DescriptorPool = m_imguiPool; + ii.MinImageCount = 2; + ii.ImageCount = m_swapchain.imageCount(); + ii.ApiVersion = VK_API_VERSION_1_3; + ii.PipelineInfoMain.RenderPass = m_swapchain.renderPass(); + ii.PipelineInfoMain.MSAASamples = VK_SAMPLE_COUNT_1_BIT; ImGui_ImplVulkan_Init(&ii); - ImGui_ImplVulkan_CreateFontsTexture(); } void Application::shutdownImGui() { diff --git a/src/lang/Expr.h b/src/lang/Expr.h index a5437b9..be1c09f 100644 --- a/src/lang/Expr.h +++ b/src/lang/Expr.h @@ -14,6 +14,7 @@ namespace chisel::lang { struct NumberLit; struct BoolLit; struct UndefLit; +struct StringLit; struct VectorLit; struct VarRef; struct BinaryExpr; @@ -23,7 +24,7 @@ struct IndexExpr; struct LetExpr; struct FunctionCall; -using ExprNode = std::variant; using ExprPtr = std::unique_ptr; @@ -50,6 +51,11 @@ struct UndefLit { SourceLoc loc; }; +struct StringLit { + std::string value; + SourceLoc loc; +}; + struct VectorLit { std::vector elements; SourceLoc loc; diff --git a/src/lang/Interpreter.cpp b/src/lang/Interpreter.cpp index 5db3159..f190f53 100644 --- a/src/lang/Interpreter.cpp +++ b/src/lang/Interpreter.cpp @@ -1,6 +1,8 @@ #include "Interpreter.h" +#include #include #include +#include static constexpr double kDeg2Rad = 3.14159265358979323846 / 180.0; static constexpr double kRad2Deg = 180.0 / 3.14159265358979323846; @@ -37,6 +39,9 @@ Value Interpreter::evaluate(const ExprNode& expr) { else if constexpr (std::is_same_v) { return Value::undef(); } + else if constexpr (std::is_same_v) { + return Value::fromString(node.value); + } else if constexpr (std::is_same_v) { std::vector elems; elems.reserve(node.elements.size()); @@ -397,6 +402,55 @@ Value Interpreter::callBuiltin(const std::string& name, return Value::undef(); } + // ---- rands(min, max, count [, seed]) ---- + if (name == "rands") { + if (args.size() < 3) return Value::undef(); + double minVal = num(0); + double maxVal = num(1); + int count = static_cast(num(2)); + if (count <= 0 || minVal > maxVal) return Value::fromVec({}); + std::mt19937_64 rng; + if (args.size() >= 4 && args[3].isNumber()) + rng.seed(static_cast(args[3].asNumber())); + else + rng.seed(std::random_device{}()); + std::uniform_real_distribution dist(minVal, maxVal); + std::vector result; + result.reserve(static_cast(count)); + for (int i = 0; i < count; ++i) + result.push_back(Value::fromNumber(dist(rng))); + return Value::fromVec(std::move(result)); + } + + // ---- lookup(key, [[k0,v0],[k1,v1],...]) ---- + if (name == "lookup") { + if (args.size() < 2 || !args[0].isNumber() || !args[1].isVector()) return Value::undef(); + double key = args[0].asNumber(); + const auto& table = args[1].asVec(); + if (table.empty()) return Value::undef(); + + // Collect valid [key, val] pairs + std::vector> pairs; + for (const auto& entry : table) { + if (entry.isVector() && entry.asVec().size() >= 2 && + entry.asVec()[0].isNumber() && entry.asVec()[1].isNumber()) + pairs.push_back({entry.asVec()[0].asNumber(), entry.asVec()[1].asNumber()}); + } + if (pairs.empty()) return Value::undef(); + std::sort(pairs.begin(), pairs.end(), [](const auto& a, const auto& b){ return a.first < b.first; }); + + if (key <= pairs.front().first) return Value::fromNumber(pairs.front().second); + if (key >= pairs.back().first) return Value::fromNumber(pairs.back().second); + + for (std::size_t i = 1; i < pairs.size(); ++i) { + if (key <= pairs[i].first) { + double t = (key - pairs[i-1].first) / (pairs[i].first - pairs[i-1].first); + return Value::fromNumber(pairs[i-1].second + t * (pairs[i].second - pairs[i-1].second)); + } + } + return Value::fromNumber(pairs.back().second); + } + return Value::undef(); // unknown function } diff --git a/src/lang/Lexer.cpp b/src/lang/Lexer.cpp index e42fb34..21bc8ee 100644 --- a/src/lang/Lexer.cpp +++ b/src/lang/Lexer.cpp @@ -74,6 +74,12 @@ std::vector Lexer::tokenize() { uint32_t startOffset = static_cast(m_pos); + // String literals + if (c == '"') { + tokens.push_back(scanString(startOffset)); + continue; + } + // Numbers: digits or leading dot (e.g. .5) if (std::isdigit(static_cast(c)) || (c == '.' && std::isdigit(static_cast(peek(1))))) { @@ -276,6 +282,41 @@ Token Lexer::scanSpecialVar(uint32_t startOffset) { return t; } +Token Lexer::scanString(uint32_t startOffset) { + uint32_t startLine = m_line; + uint32_t startCol = m_col; + + advance(); // consume opening '"' + std::string value; + while (!atEnd() && peek() != '"') { + char ch = advance(); + if (ch == '\\') { + if (atEnd()) break; + char esc = advance(); + switch (esc) { + case '"': value += '"'; break; + case '\\': value += '\\'; break; + case 'n': value += '\n'; break; + case 't': value += '\t'; break; + case 'r': value += '\r'; break; + default: value += esc; break; + } + } else { + value += ch; + } + } + if (!atEnd()) advance(); // consume closing '"' + else addError("unterminated string literal", {startLine, startCol, startOffset}); + + Token t; + t.kind = TokenKind::String; + t.loc.offset = startOffset; + t.loc.line = startLine; + t.loc.col = startCol; + t.text = std::move(value); + return t; +} + void Lexer::skipLineComment() { // consume '//' advance(); advance(); diff --git a/src/lang/Lexer.h b/src/lang/Lexer.h index f2e4f79..d975f9d 100644 --- a/src/lang/Lexer.h +++ b/src/lang/Lexer.h @@ -35,6 +35,7 @@ class Lexer { Token scanNumber(uint32_t startOffset); Token scanIdentOrKeyword(uint32_t startOffset); Token scanSpecialVar(uint32_t startOffset); + Token scanString(uint32_t startOffset); void skipLineComment(); void skipBlockComment(); diff --git a/src/lang/Parser.cpp b/src/lang/Parser.cpp index 32bcac7..154719f 100644 --- a/src/lang/Parser.cpp +++ b/src/lang/Parser.cpp @@ -534,6 +534,11 @@ ExprPtr Parser::parsePrimary() { const Token& tok = advance(); return makeExpr(NumberLit{tok.numberValue(), tok.loc}); } + // String literal + if (check(TokenKind::String)) { + const Token& tok = advance(); + return makeExpr(StringLit{tok.text, tok.loc}); + } // Bool literals if (check(TokenKind::True)) { SourceLoc loc = advance().loc; diff --git a/src/lang/Token.h b/src/lang/Token.h index 3e9a511..72a2f16 100644 --- a/src/lang/Token.h +++ b/src/lang/Token.h @@ -21,6 +21,7 @@ struct SourceLoc { enum class TokenKind : uint8_t { // Literals Number, // 3.14 .5 1e3 + String, // "hello" True, // true False, // false diff --git a/tests/test_interpreter.cpp b/tests/test_interpreter.cpp index e3197dc..f3bbcdb 100644 --- a/tests/test_interpreter.cpp +++ b/tests/test_interpreter.cpp @@ -346,3 +346,113 @@ TEST_CASE("Interp:str function", "[interp][tier-a]") { REQUIRE(evalNum("len(str(42))") == Approx(2.0)); REQUIRE(evalNum("len(str(3.14))") == Approx(4.0)); } + +// --------------------------------------------------------------------------- +// Tier B: string literals +// --------------------------------------------------------------------------- +static Value evalVal(std::string_view src) { + std::string full = "_v = "; + full += src; + full += ";"; + Lexer lexer(full); + auto tokens = lexer.tokenize(); + REQUIRE_FALSE(lexer.hasErrors()); + Parser parser(std::move(tokens)); + auto result = parser.parse(); + REQUIRE_FALSE(parser.hasErrors()); + REQUIRE(result.assignments.size() == 1); + Interpreter interp; + return interp.evaluate(*result.assignments[0].value); +} + +TEST_CASE("Interp:string literal basic", "[interp][tier-b]") { + Value v = evalVal("\"hello\""); + REQUIRE(v.isString()); + REQUIRE(v.asString() == "hello"); +} + +TEST_CASE("Interp:string literal escape sequences", "[interp][tier-b]") { + Value v = evalVal("\"a\\\"b\""); + REQUIRE(v.isString()); + REQUIRE(v.asString() == "a\"b"); +} + +TEST_CASE("Interp:string literal empty", "[interp][tier-b]") { + Value v = evalVal("\"\""); + REQUIRE(v.isString()); + REQUIRE(v.asString().empty()); +} + +TEST_CASE("Interp:len on string literal", "[interp][tier-b]") { + REQUIRE(evalNum("len(\"hello\")") == Approx(5.0)); + REQUIRE(evalNum("len(\"\")") == Approx(0.0)); +} + +TEST_CASE("Interp:str concat with string literal", "[interp][tier-b]") { + Value v = evalVal("str(\"x=\", 5)"); + REQUIRE(v.isString()); + REQUIRE(v.asString() == "x=5"); +} + +TEST_CASE("Interp:index into string literal", "[interp][tier-b]") { + Value v = evalVal("\"abc\"[1]"); + REQUIRE(v.isString()); + REQUIRE(v.asString() == "b"); +} + +TEST_CASE("Interp:chr and ord roundtrip", "[interp][tier-b]") { + REQUIRE(evalNum("ord(\"A\")") == Approx(65.0)); + Value v = evalVal("chr(65)"); + REQUIRE(v.isString()); + REQUIRE(v.asString() == "A"); +} + +// --------------------------------------------------------------------------- +// Tier B: rands +// --------------------------------------------------------------------------- +TEST_CASE("Interp:rands count", "[interp][tier-b]") { + REQUIRE(evalNum("len(rands(0, 1, 5))") == Approx(5.0)); + REQUIRE(evalNum("len(rands(0, 1, 10))") == Approx(10.0)); +} + +TEST_CASE("Interp:rands range", "[interp][tier-b]") { + // With a fixed seed, values should stay in [min,max] + REQUIRE(evalNum("rands(2, 5, 1, 42)[0]") >= 2.0); + REQUIRE(evalNum("rands(2, 5, 1, 42)[0]") <= 5.0); +} + +TEST_CASE("Interp:rands seeded deterministic", "[interp][tier-b]") { + double r1 = evalNum("rands(0, 100, 3, 99)[0]"); + double r2 = evalNum("rands(0, 100, 3, 99)[0]"); + REQUIRE(r1 == Approx(r2)); +} + +TEST_CASE("Interp:rands empty count", "[interp][tier-b]") { + REQUIRE(evalNum("len(rands(0, 1, 0))") == Approx(0.0)); +} + +// --------------------------------------------------------------------------- +// Tier B: lookup +// --------------------------------------------------------------------------- +TEST_CASE("Interp:lookup exact match", "[interp][tier-b]") { + REQUIRE(evalNum("lookup(0, [[0,10],[1,20]])") == Approx(10.0)); + REQUIRE(evalNum("lookup(1, [[0,10],[1,20]])") == Approx(20.0)); +} + +TEST_CASE("Interp:lookup interpolation", "[interp][tier-b]") { + REQUIRE(evalNum("lookup(0.5, [[0,0],[1,10]])") == Approx(5.0)); + REQUIRE(evalNum("lookup(0.25, [[0,0],[1,100]])") == Approx(25.0)); +} + +TEST_CASE("Interp:lookup clamp below", "[interp][tier-b]") { + REQUIRE(evalNum("lookup(-1, [[0,5],[1,10]])") == Approx(5.0)); +} + +TEST_CASE("Interp:lookup clamp above", "[interp][tier-b]") { + REQUIRE(evalNum("lookup(99, [[0,5],[1,10]])") == Approx(10.0)); +} + +TEST_CASE("Interp:lookup unsorted table", "[interp][tier-b]") { + // lookup must sort by key + REQUIRE(evalNum("lookup(0.5, [[1,10],[0,0]])") == Approx(5.0)); +} diff --git a/tests/test_lexer.cpp b/tests/test_lexer.cpp index ae8d05d..d433684 100644 --- a/tests/test_lexer.cpp +++ b/tests/test_lexer.cpp @@ -305,3 +305,35 @@ TEST_CASE("Lexer:linear_extrude with underscores tokenizes as single keyword", " REQUIRE(t[0].kind == TokenKind::LinearExtrude); REQUIRE(t[0].text == "linear_extrude"); } + +// --------------------------------------------------------------------------- +// Tier B: string literals +// --------------------------------------------------------------------------- +TEST_CASE("Lexer:string literal basic", "[lexer][tier-b]") { + auto t = lex("\"hello\""); + REQUIRE(t.size() == 1); + REQUIRE(t[0].kind == TokenKind::String); + REQUIRE(t[0].text == "hello"); +} + +TEST_CASE("Lexer:string literal empty", "[lexer][tier-b]") { + auto t = lex("\"\""); + REQUIRE(t.size() == 1); + REQUIRE(t[0].kind == TokenKind::String); + REQUIRE(t[0].text.empty()); +} + +TEST_CASE("Lexer:string escape sequences", "[lexer][tier-b]") { + auto t = lex("\"a\\\"b\\\\c\""); + REQUIRE(t.size() == 1); + REQUIRE(t[0].kind == TokenKind::String); + REQUIRE(t[0].text == "a\"b\\c"); +} + +TEST_CASE("Lexer:string in assignment context", "[lexer][tier-b]") { + auto t = kinds("s = \"world\";"); + REQUIRE(t[0] == TokenKind::Ident); + REQUIRE(t[1] == TokenKind::Equals); + REQUIRE(t[2] == TokenKind::String); + REQUIRE(t[3] == TokenKind::Semicolon); +} diff --git a/tests/tier_b_test.scad b/tests/tier_b_test.scad new file mode 100644 index 0000000..60c97cb --- /dev/null +++ b/tests/tier_b_test.scad @@ -0,0 +1,51 @@ +// Tier B visual test — string literals, rands, lookup +// Open in ChiselCAD (and optionally OpenSCAD) to compare results. + +// --- Scene 1: lookup — height profile tower +// lookup maps x position → cylinder height via linear interpolation +table = [[0, 2], [5, 8], [10, 4], [15, 12], [20, 6]]; +for (x = [0, 5, 10, 15, 20]) + translate([x, 0, 0]) + cylinder(h = lookup(x, table), r = 1.8, $fn = 16); + +// --- Scene 2: rands — random sphere cluster with fixed seed +// 8 spheres placed at random offsets, all within a known range +offsets = rands(-8, 8, 8, 1337); +for (i = [0:7]) + translate([offsets[i], 20, 0]) + sphere(r = 1.2, $fn = 12); + +// --- Scene 3: string literal length drives geometry +// len("chisel") == 6 → 6 columns +word = "chisel"; +for (i = [0 : len(word) - 1]) + translate([i * 4, 40, 0]) + cylinder(h = ord(word[i]) - 90, r = 1.5, $fn = 12); + +// --- Scene 4: str() used to build a label height +// str(42) has 2 chars → height 2 +label_h = len(str(42)); +translate([0, 60, 0]) + cube([10, 5, label_h]); + +// --- Scene 5: inverse trig drives an angle layout (Tier B math) +// asin(0.5) == 30 degrees, acos(0.5) == 60 degrees +// Build two cylinders rotated by these angles +translate([20, 60, 0]) + rotate([0, asin(0.5), 0]) + cylinder(h = 8, r = 1, $fn = 8); + +translate([30, 60, 0]) + rotate([0, acos(0.5), 0]) + cylinder(h = 8, r = 1, $fn = 8); + +// --- Scene 6: norm + cross (Tier B vector math) +// norm([3,4,0]) == 5; use as sphere radius +r_val = norm([3, 4, 0]); +translate([0, 80, 0]) + sphere(r = r_val, $fn = 32); + +// cross([1,0,0],[0,1,0]) == [0,0,1]; translate along the cross product +v = cross([1, 0, 0], [0, 1, 0]); +translate([v[0] * 10 + 16, v[1] * 10 + 80, v[2] * 10]) + sphere(r = 2, $fn = 16); diff --git a/vcpkg-configuration.json b/vcpkg-configuration.json new file mode 100644 index 0000000..78b5bf4 --- /dev/null +++ b/vcpkg-configuration.json @@ -0,0 +1,8 @@ +{ + "default-registry": { + "kind": "git", + "repository": "https://github.com/microsoft/vcpkg", + "baseline": "6b07d2d37301e9e7c6fcf771536d2ff6585c5912" + }, + "registries": [] +} diff --git a/vcpkg.json b/vcpkg.json index bf63d42..cc16f39 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -16,7 +16,6 @@ "glm", "manifold", "spdlog", - "nlohmann-json", - "catch2" + "nlohmann-json" ] }