From d16cf0e4491e4e517c37d09e008c4956f4fa8e85 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Fri, 24 Apr 2026 14:50:14 -0700 Subject: [PATCH 1/5] feat(lang): add 2-D primitives and extrusion ops (Tier 4) Adds square(), circle(), polygon(), linear_extrude(), and rotate_extrude(). 2-D primitives are parsed as PrimitiveNode (Square2D/Circle2D/Polygon2D) and compiled to CsgLeaf in the CSG IR. polygon() stores resolved contour points and optional path indices directly on the leaf. Extrusions are a new AST/IR node (ExtrusionNode / CsgExtrusion). MeshEvaluator converts 2-D children to manifold::CrossSection via getChildCrossSection(), which handles nested 2-D booleans and 2-D transforms (translate/rotate in XY applied as mat2x3). The resulting CrossSection is extruded with Manifold::Extrude() (height, twist, scale, center) or Manifold::Revolve() (angle, $fn). Co-Authored-By: Claude Sonnet 4.6 --- src/csg/CsgEvaluator.cpp | 146 +++++++++++++++++++++++++++++++++++-- src/csg/CsgEvaluator.h | 1 + src/csg/CsgNode.h | 33 ++++++++- src/csg/MeshEvaluator.cpp | 113 +++++++++++++++++++++++++++- src/csg/MeshEvaluator.h | 9 ++- src/csg/PrimitiveGen.cpp | 56 +++++++++++++- src/csg/PrimitiveGen.h | 7 +- src/lang/AST.h | 23 +++++- src/lang/Lexer.cpp | 13 +++- src/lang/Parser.cpp | 58 +++++++++++++++ src/lang/Parser.h | 2 + src/lang/Token.h | 5 ++ tests/2d_extrude_test.scad | 60 +++++++++++++++ tests/test_lexer.cpp | 20 +++++ tests/test_parser.cpp | 82 +++++++++++++++++++++ 15 files changed, 601 insertions(+), 27 deletions(-) create mode 100644 tests/2d_extrude_test.scad diff --git a/src/csg/CsgEvaluator.cpp b/src/csg/CsgEvaluator.cpp index 6ed44b2..b0c0f02 100644 --- a/src/csg/CsgEvaluator.cpp +++ b/src/csg/CsgEvaluator.cpp @@ -59,6 +59,8 @@ CsgNodePtr CsgEvaluator::evalNode(const AstNode& node, const glm::mat4& xform) { return evalFor(n, xform); else if constexpr (std::is_same_v) return evalModuleCall(n, xform); + else if constexpr (std::is_same_v) + return evalExtrusion(n, xform); return nullptr; }, node); } @@ -68,18 +70,107 @@ CsgNodePtr CsgEvaluator::evalNode(const AstNode& node, const glm::mat4& xform) { // --------------------------------------------------------------------------- CsgNodePtr CsgEvaluator::evalPrimitive(const PrimitiveNode& p, const glm::mat4& xform) { CsgLeaf leaf; + leaf.center = p.center; + leaf.transform = xform; + switch (p.kind) { - case PrimitiveNode::Kind::Cube: leaf.kind = CsgLeaf::Kind::Cube; break; - case PrimitiveNode::Kind::Sphere: leaf.kind = CsgLeaf::Kind::Sphere; break; - case PrimitiveNode::Kind::Cylinder: leaf.kind = CsgLeaf::Kind::Cylinder; break; + // ---- 3-D primitives: resolve all params as scalars -------------------- + case PrimitiveNode::Kind::Cube: + leaf.kind = CsgLeaf::Kind::Cube; + for (const auto& [name, exprPtr] : p.params) + leaf.params[name] = m_interp->evalNumber(*exprPtr); + break; + + case PrimitiveNode::Kind::Sphere: + leaf.kind = CsgLeaf::Kind::Sphere; + for (const auto& [name, exprPtr] : p.params) + leaf.params[name] = m_interp->evalNumber(*exprPtr); + break; + + case PrimitiveNode::Kind::Cylinder: + leaf.kind = CsgLeaf::Kind::Cylinder; + for (const auto& [name, exprPtr] : p.params) + leaf.params[name] = m_interp->evalNumber(*exprPtr); + break; + + // ---- square([w,h]) / square(s) / square(size=[w,h]) ------------------ + case PrimitiveNode::Kind::Square2D: { + leaf.kind = CsgLeaf::Kind::Square2D; + // Resolve scalar params ($fn, etc.) — skip "size" which may be a vector + for (const auto& [name, exprPtr] : p.params) { + if (name == "size") continue; + leaf.params[name] = m_interp->evalNumber(*exprPtr); + } + // "size" overrides x/y if present + if (p.params.count("size")) { + Value sv = m_interp->evaluate(*p.params.at("size")); + if (sv.isNumber()) { + leaf.params["sx"] = sv.asNumber(); + leaf.params["sy"] = sv.asNumber(); + } else if (sv.isVector() && sv.asVec().size() >= 2) { + leaf.params["sx"] = sv.asVec()[0].asNumber(); + leaf.params["sy"] = sv.asVec()[1].asNumber(); + } + } + // If sx/sy not set yet, fall back to positional vector "x","y" or scalar "_pos0" + if (!leaf.params.count("sx")) { + double sx = leaf.params.count("x") ? leaf.params["x"] : 1.0; + double sy = leaf.params.count("y") ? leaf.params["y"] : 1.0; + if (leaf.params.count("_pos0")) { + double s = leaf.params["_pos0"]; + sx = sy = s; + } + leaf.params["sx"] = sx; + leaf.params["sy"] = sy; + } + break; } - // Resolve every expression param to a concrete double - for (const auto& [name, exprPtr] : p.params) - leaf.params[name] = m_interp->evalNumber(*exprPtr); + // ---- circle(r) / circle(d) -------------------------------------------- + case PrimitiveNode::Kind::Circle2D: + leaf.kind = CsgLeaf::Kind::Circle2D; + for (const auto& [name, exprPtr] : p.params) + leaf.params[name] = m_interp->evalNumber(*exprPtr); + // diameter → radius + if (!leaf.params.count("r") && leaf.params.count("d")) + leaf.params["r"] = leaf.params["d"] * 0.5; + break; + + // ---- polygon(points=[[x,y],...], paths=[[i,j,...],...]) --------------- + case PrimitiveNode::Kind::Polygon2D: { + leaf.kind = CsgLeaf::Kind::Polygon2D; + if (p.params.count("points")) { + Value pts = m_interp->evaluate(*p.params.at("points")); + if (pts.isVector()) { + leaf.polyPoints.reserve(pts.asVec().size()); + for (const auto& pt : pts.asVec()) { + if (pt.isVector() && pt.asVec().size() >= 2) { + leaf.polyPoints.push_back({ + static_cast(pt.asVec()[0].asNumber()), + static_cast(pt.asVec()[1].asNumber()) + }); + } + } + } + } + if (p.params.count("paths")) { + Value paths = m_interp->evaluate(*p.params.at("paths")); + if (paths.isVector()) { + for (const auto& path : paths.asVec()) { + if (path.isVector()) { + std::vector indices; + indices.reserve(path.asVec().size()); + for (const auto& idx : path.asVec()) + indices.push_back(static_cast(idx.asNumber())); + leaf.polyPaths.push_back(std::move(indices)); + } + } + } + } + break; + } + } - leaf.center = p.center; - leaf.transform = xform; return makeLeaf(std::move(leaf)); } @@ -319,4 +410,43 @@ CsgNodePtr CsgEvaluator::evalModuleCall(const ModuleCallNode& call, const glm::m return makeBoolean(std::move(u)); } +// --------------------------------------------------------------------------- +// Extrusion — build a CsgExtrusion from an ExtrusionNode +// --------------------------------------------------------------------------- +CsgNodePtr CsgEvaluator::evalExtrusion(const ExtrusionNode& e, const glm::mat4& xform) { + CsgExtrusion ext; + ext.kind = (e.kind == ExtrusionNode::Kind::Linear) ? CsgExtrusion::Kind::Linear + : CsgExtrusion::Kind::Rotate; + ext.transform = xform; + + // Resolve numeric params; treat "scale" and "center" specially + for (const auto& [name, exprPtr] : e.params) { + if (name == "scale") { + Value sv = m_interp->evaluate(*exprPtr); + if (sv.isNumber()) { + ext.params["scale_x"] = sv.asNumber(); + ext.params["scale_y"] = sv.asNumber(); + } else if (sv.isVector() && sv.asVec().size() >= 2) { + ext.params["scale_x"] = sv.asVec()[0].asNumber(); + ext.params["scale_y"] = sv.asVec()[1].asNumber(); + } + } else if (name == "center") { + Value cv = m_interp->evaluate(*exprPtr); + ext.params["center"] = bool(cv) ? 1.0 : 0.0; + } else { + ext.params[name] = m_interp->evalNumber(*exprPtr); + } + } + + // Evaluate 2-D children in local space (identity xform) + // The outer xform is stored in ext.transform and applied to the final solid. + const glm::mat4 identity{1.0f}; + for (const auto& child : e.children) { + if (auto c = evalNode(*child, identity)) + ext.children.push_back(std::move(c)); + } + + return makeExtrusion(std::move(ext)); +} + } // namespace chisel::csg diff --git a/src/csg/CsgEvaluator.h b/src/csg/CsgEvaluator.h index 441358d..a42696f 100644 --- a/src/csg/CsgEvaluator.h +++ b/src/csg/CsgEvaluator.h @@ -35,6 +35,7 @@ class CsgEvaluator { CsgNodePtr evalIf(const chisel::lang::IfNode& n, const glm::mat4& xform); CsgNodePtr evalFor(const chisel::lang::ForNode& n, const glm::mat4& xform); CsgNodePtr evalModuleCall(const chisel::lang::ModuleCallNode& n, const glm::mat4& xform); + CsgNodePtr evalExtrusion(const chisel::lang::ExtrusionNode& e, const glm::mat4& xform); glm::mat4 makeMatrix(const chisel::lang::TransformNode& t) const; }; diff --git a/src/csg/CsgNode.h b/src/csg/CsgNode.h index f4f1896..d480ec1 100644 --- a/src/csg/CsgNode.h +++ b/src/csg/CsgNode.h @@ -9,9 +9,10 @@ namespace chisel::csg { // --------------------------------------------------------------------------- -// Forward declaration needed for the recursive variant +// Forward declarations needed for the recursive variant // --------------------------------------------------------------------------- struct CsgBoolean; +struct CsgExtrusion; // --------------------------------------------------------------------------- // CsgLeaf — a primitive with its fully-accumulated world transform. @@ -19,20 +20,25 @@ struct CsgBoolean; // this node in the AST; the MeshEvaluator applies it after tessellation. // --------------------------------------------------------------------------- struct CsgLeaf { - enum class Kind { Cube, Sphere, Cylinder }; + enum class Kind { Cube, Sphere, Cylinder, Square2D, Circle2D, Polygon2D }; Kind kind = Kind::Cube; // Named params carried forward from the parser ("r", "h", "r1", "r2", - // "$fn", "$fs", "$fa", "x"/"y"/"z" for cube size, "_pos0" for sphere(5)). + // "$fn", "$fs", "$fa", "x"/"y"/"z" for cube size, "_pos0" for sphere(5), + // "sx"/"sy" for square, etc.) std::unordered_map params; bool center = false; glm::mat4 transform{1.0f}; // accumulated model-to-world matrix + + // Polygon2D only — resolved contour points and optional path indices + std::vector polyPoints; + std::vector> polyPaths; }; // --------------------------------------------------------------------------- // CsgNode — the CSG IR variant // --------------------------------------------------------------------------- -using CsgNode = std::variant; +using CsgNode = std::variant; using CsgNodePtr = std::shared_ptr; struct CsgBoolean { @@ -47,6 +53,22 @@ struct CsgBoolean { glm::mat4 transform{1.0f}; }; +// --------------------------------------------------------------------------- +// CsgExtrusion — linear_extrude / rotate_extrude in the CSG IR. +// Children are 2-D CsgLeafs (Square2D / Circle2D / Polygon2D) or CsgBooleans +// of 2-D leaves; MeshEvaluator converts them to CrossSections. +// --------------------------------------------------------------------------- +struct CsgExtrusion { + enum class Kind { Linear, Rotate }; + + Kind kind = Kind::Linear; + // Resolved numeric params: "height", "twist", "scale_x", "scale_y", + // "angle", "$fn", "center", "_pos0" + std::unordered_map params; + std::vector children; + glm::mat4 transform{1.0f}; // outer 3-D transform applied to the final solid +}; + // --------------------------------------------------------------------------- // Factory helpers // --------------------------------------------------------------------------- @@ -56,6 +78,9 @@ inline CsgNodePtr makeLeaf(CsgLeaf leaf) { inline CsgNodePtr makeBoolean(CsgBoolean b) { return std::make_shared(std::move(b)); } +inline CsgNodePtr makeExtrusion(CsgExtrusion e) { + return std::make_shared(std::move(e)); +} // --------------------------------------------------------------------------- // CsgScene — output of CsgEvaluator diff --git a/src/csg/MeshEvaluator.cpp b/src/csg/MeshEvaluator.cpp index c722bb1..761f471 100644 --- a/src/csg/MeshEvaluator.cpp +++ b/src/csg/MeshEvaluator.cpp @@ -25,9 +25,12 @@ static std::string leafKey(const CsgLeaf& leaf) { k.reserve(256); switch (leaf.kind) { - case CsgLeaf::Kind::Cube: k += "cube:"; break; - case CsgLeaf::Kind::Sphere: k += "sphere:"; break; - case CsgLeaf::Kind::Cylinder: k += "cylinder:"; break; + case CsgLeaf::Kind::Cube: k += "cube:"; break; + case CsgLeaf::Kind::Sphere: k += "sphere:"; break; + case CsgLeaf::Kind::Cylinder: k += "cylinder:"; break; + case CsgLeaf::Kind::Square2D: k += "square2d:"; break; + case CsgLeaf::Kind::Circle2D: k += "circle2d:"; break; + case CsgLeaf::Kind::Polygon2D: k += "polygon2d:"; break; } // Sort params for a stable key @@ -87,8 +90,10 @@ manifold::Manifold MeshEvaluator::evalNode(const CsgNode& node, const PrimitiveG using T = std::decay_t; if constexpr (std::is_same_v) return evalLeaf(n, gen); - else + else if constexpr (std::is_same_v) return evalBoolean(n, gen); + else + return evalExtrusion(n, gen); }, node); } @@ -137,4 +142,104 @@ manifold::Manifold MeshEvaluator::evalBoolean(const CsgBoolean& b, const Primiti return result; } +// --------------------------------------------------------------------------- +// getChildCrossSection — recursively build a CrossSection from a 2-D subtree. +// Handles CsgLeaf (2-D kinds) and CsgBoolean (union/difference/intersection +// of 2-D children). 3-D leaves produce an empty CrossSection. +// --------------------------------------------------------------------------- +manifold::CrossSection MeshEvaluator::getChildCrossSection(const CsgNode& node, + const PrimitiveGen& gen) { + return std::visit([&](const auto& n) -> manifold::CrossSection { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + auto cs = gen.generateCrossSection(n); + // Apply the 2-D portion of the accumulated transform so that + // translate/rotate applied to 2-D children is respected. + if (n.transform != glm::mat4{1.0f}) { + manifold::mat2x3 m2; + m2[0][0] = n.transform[0][0]; m2[0][1] = n.transform[0][1]; + m2[1][0] = n.transform[1][0]; m2[1][1] = n.transform[1][1]; + m2[2][0] = n.transform[3][0]; m2[2][1] = n.transform[3][1]; + cs = cs.Transform(m2); + } + return cs; + } else if constexpr (std::is_same_v) { + if (n.children.empty()) return {}; + auto result = getChildCrossSection(*n.children[0], gen); + for (std::size_t i = 1; i < n.children.size(); ++i) { + auto child = getChildCrossSection(*n.children[i], gen); + switch (n.op) { + case CsgBoolean::Op::Union: result = result + child; break; + case CsgBoolean::Op::Difference: result = result - child; break; + case CsgBoolean::Op::Intersection: result = result ^ child; break; + default: result = result + child; break; // hull/minkowski → union + } + } + return result; + } else { + return {}; // nested extrusion — not supported + } + }, node); +} + +// --------------------------------------------------------------------------- +// evalExtrusion — convert a CsgExtrusion node to a 3-D Manifold +// --------------------------------------------------------------------------- +manifold::Manifold MeshEvaluator::evalExtrusion(const CsgExtrusion& e, + const PrimitiveGen& gen) { + // Build the unified 2-D cross-section from all children + manifold::CrossSection cs; + for (const auto& child : e.children) + cs = cs + getChildCrossSection(*child, gen); + + if (cs.IsEmpty()) return {}; + + auto getP = [&](const std::string& k, double def) -> double { + auto it = e.params.find(k); + return (it != e.params.end()) ? it->second : def; + }; + + manifold::Manifold result; + + if (e.kind == CsgExtrusion::Kind::Linear) { + double height = getP("height", getP("h", getP("_pos0", 1.0))); + double twist = getP("twist", 0.0); + float scaleX = static_cast(getP("scale_x", 1.0)); + float scaleY = static_cast(getP("scale_y", 1.0)); + double fnOvr = getP("$fn", 0.0); + int nDivs = (twist != 0.0 || scaleX != 1.0f || scaleY != 1.0f) + ? std::max(1, static_cast(fnOvr > 0 ? fnOvr : 10)) + : 0; + bool center = (getP("center", 0.0) != 0.0); + + result = manifold::Manifold::Extrude( + cs.ToPolygons(), + static_cast(height), + nDivs, + static_cast(twist), + {scaleX, scaleY}); + + if (center) + result = result.Translate({0.0f, 0.0f, + -static_cast(height) * 0.5f}); + } else { + // rotate_extrude + double angle = getP("angle", 360.0); + double fnOvr = getP("$fn", 0.0); + int segs = gen.resolveSegments(10.0, fnOvr); // 10 = proxy radius + + result = manifold::Manifold::Revolve( + cs.ToPolygons(), + segs, + static_cast(angle)); + } + + // Apply the outer 3-D world transform + if (e.transform != glm::mat4{1.0f}) + result = result.Transform(toAffine(e.transform)); + + return result; +} + } // namespace chisel::csg diff --git a/src/csg/MeshEvaluator.h b/src/csg/MeshEvaluator.h index 0e9d870..ae0e7aa 100644 --- a/src/csg/MeshEvaluator.h +++ b/src/csg/MeshEvaluator.h @@ -3,6 +3,7 @@ #include "MeshCache.h" #include "PrimitiveGen.h" #include +#include #include namespace chisel::csg { @@ -28,9 +29,11 @@ class MeshEvaluator { std::vector evaluate(const CsgScene& scene); private: - manifold::Manifold evalNode(const CsgNode& node, const PrimitiveGen& gen); - manifold::Manifold evalLeaf(const CsgLeaf& leaf, const PrimitiveGen& gen); - manifold::Manifold evalBoolean(const CsgBoolean& b, const PrimitiveGen& gen); + manifold::Manifold evalNode(const CsgNode& node, const PrimitiveGen& gen); + manifold::Manifold evalLeaf(const CsgLeaf& leaf, const PrimitiveGen& gen); + manifold::Manifold evalBoolean(const CsgBoolean& b, const PrimitiveGen& gen); + manifold::Manifold evalExtrusion(const CsgExtrusion& e, const PrimitiveGen& gen); + manifold::CrossSection getChildCrossSection(const CsgNode& node, const PrimitiveGen& gen); MeshCache& m_cache; }; diff --git a/src/csg/PrimitiveGen.cpp b/src/csg/PrimitiveGen.cpp index 4b591f0..7c96a18 100644 --- a/src/csg/PrimitiveGen.cpp +++ b/src/csg/PrimitiveGen.cpp @@ -166,9 +166,63 @@ manifold::Manifold PrimitiveGen::generate(const CsgLeaf& leaf) const { segs, leaf.center); } + + case CsgLeaf::Kind::Square2D: + case CsgLeaf::Kind::Circle2D: + case CsgLeaf::Kind::Polygon2D: + return {}; // 2-D only — use generateCrossSection() instead } - return {}; // unreachable + return {}; +} + +// --------------------------------------------------------------------------- +// generateCrossSection — 2-D leaf → Manifold CrossSection +// --------------------------------------------------------------------------- +manifold::CrossSection PrimitiveGen::generateCrossSection(const CsgLeaf& leaf) const { + const auto& p = leaf.params; + + switch (leaf.kind) { + case CsgLeaf::Kind::Square2D: { + double sx = getParam(p, "sx", 1.0); + double sy = getParam(p, "sy", 1.0); + return manifold::CrossSection::Square( + {static_cast(sx), static_cast(sy)}, leaf.center); + } + + case CsgLeaf::Kind::Circle2D: { + double r = getParam(p, "r", getParam(p, "_pos0", 1.0)); + double fnOvr = getParam(p, "$fn", 0.0); + int segs = resolveSegments(r, fnOvr); + return manifold::CrossSection::Circle(static_cast(r), segs); + } + + case CsgLeaf::Kind::Polygon2D: { + manifold::Polygons polys; + if (!leaf.polyPaths.empty()) { + for (const auto& path : leaf.polyPaths) { + manifold::SimplePolygon contour; + contour.reserve(path.size()); + for (int idx : path) { + if (idx >= 0 && static_cast(idx) < leaf.polyPoints.size()) + contour.push_back({leaf.polyPoints[idx].x, + leaf.polyPoints[idx].y}); + } + if (!contour.empty()) polys.push_back(std::move(contour)); + } + } else { + manifold::SimplePolygon contour; + contour.reserve(leaf.polyPoints.size()); + for (const auto& pt : leaf.polyPoints) + contour.push_back({pt.x, pt.y}); + if (!contour.empty()) polys.push_back(std::move(contour)); + } + return polys.empty() ? manifold::CrossSection{} : manifold::CrossSection(polys); + } + + default: + return {}; // 3-D primitive — no 2-D representation + } } } // namespace chisel::csg diff --git a/src/csg/PrimitiveGen.h b/src/csg/PrimitiveGen.h index cd3c7f1..e5bc5ec 100644 --- a/src/csg/PrimitiveGen.h +++ b/src/csg/PrimitiveGen.h @@ -1,6 +1,7 @@ #pragma once #include "CsgNode.h" #include +#include namespace chisel::csg { @@ -23,10 +24,14 @@ struct PrimitiveGen { // different tessellation and facet counts). bool useManifoldSphere = false; - // Generate the untransformed Manifold for a leaf. + // Generate the untransformed Manifold for a 3-D leaf. // The caller (MeshEvaluator) applies leaf.transform afterwards. manifold::Manifold generate(const CsgLeaf& leaf) const; + // Generate a 2-D CrossSection for a 2-D leaf (Square2D / Circle2D / Polygon2D). + // Returns an empty CrossSection for 3-D leaf kinds. + manifold::CrossSection generateCrossSection(const CsgLeaf& leaf) const; + // Compute how many circular segments to use for a feature of radius r, // respecting $fn / $fs / $fa in the same way OpenSCAD does. int resolveSegments(double r, double fnOverride) const; diff --git a/src/lang/AST.h b/src/lang/AST.h index c3fb877..464e47a 100644 --- a/src/lang/AST.h +++ b/src/lang/AST.h @@ -16,20 +16,21 @@ struct TransformNode; struct IfNode; struct ForNode; struct ModuleCallNode; +struct ExtrusionNode; // --------------------------------------------------------------------------- // AstNode — the top-level variant // All nodes are heap-allocated via unique_ptr so the tree is // easy to move/own and the variant stays small. // --------------------------------------------------------------------------- -using AstNode = std::variant; +using AstNode = std::variant; using AstNodePtr = std::unique_ptr; // --------------------------------------------------------------------------- // PrimitiveNode — cube / sphere / cylinder // --------------------------------------------------------------------------- struct PrimitiveNode { - enum class Kind { Cube, Sphere, Cylinder }; + enum class Kind { Cube, Sphere, Cylinder, Square2D, Circle2D, Polygon2D }; Kind kind; // Named params: "r", "h", "r1", "r2", "$fn", "$fs", "$fa", etc. @@ -165,6 +166,24 @@ inline AstNodePtr makeModuleCall(ModuleCallNode n) { return std::make_unique(std::move(n)); } +// --------------------------------------------------------------------------- +// ExtrusionNode — linear_extrude / rotate_extrude +// Wraps 2-D children and extrudes them into a 3-D solid. +// --------------------------------------------------------------------------- +struct ExtrusionNode { + enum class Kind { Linear, Rotate }; + + Kind kind; + // Named params stored as expressions (height, twist, scale, angle, $fn …) + std::unordered_map params; + std::vector children; // 2-D geometry + SourceLoc loc; +}; + +inline AstNodePtr makeExtrusion(ExtrusionNode n) { + return std::make_unique(std::move(n)); +} + // --------------------------------------------------------------------------- // ParseResult — the output of a successful parse // --------------------------------------------------------------------------- diff --git a/src/lang/Lexer.cpp b/src/lang/Lexer.cpp index 23f058d..8fef308 100644 --- a/src/lang/Lexer.cpp +++ b/src/lang/Lexer.cpp @@ -23,10 +23,15 @@ static const std::unordered_map kKeywords = { {"rotate", TokenKind::Rotate}, {"scale", TokenKind::Scale}, {"mirror", TokenKind::Mirror}, - {"if", TokenKind::If}, - {"else", TokenKind::Else}, - {"for", TokenKind::For}, - {"module", TokenKind::Module}, + {"if", TokenKind::If}, + {"else", TokenKind::Else}, + {"for", TokenKind::For}, + {"module", TokenKind::Module}, + {"square", TokenKind::Square}, + {"circle", TokenKind::Circle}, + {"polygon", TokenKind::Polygon}, + {"linear_extrude", TokenKind::LinearExtrude}, + {"rotate_extrude", TokenKind::RotateExtrude}, }; // --------------------------------------------------------------------------- diff --git a/src/lang/Parser.cpp b/src/lang/Parser.cpp index 5d4b504..1d452bb 100644 --- a/src/lang/Parser.cpp +++ b/src/lang/Parser.cpp @@ -125,8 +125,15 @@ AstNodePtr Parser::parseNode() { case TokenKind::Cube: case TokenKind::Sphere: case TokenKind::Cylinder: + case TokenKind::Square: + case TokenKind::Circle: + case TokenKind::Polygon: return parsePrimitive(k); + case TokenKind::LinearExtrude: + case TokenKind::RotateExtrude: + return parseExtrusion(k); + case TokenKind::Union: case TokenKind::Difference: case TokenKind::Intersection: @@ -169,6 +176,9 @@ AstNodePtr Parser::parsePrimitive(TokenKind k) { case TokenKind::Cube: node.kind = PrimitiveNode::Kind::Cube; break; case TokenKind::Sphere: node.kind = PrimitiveNode::Kind::Sphere; break; case TokenKind::Cylinder: node.kind = PrimitiveNode::Kind::Cylinder; break; + case TokenKind::Square: node.kind = PrimitiveNode::Kind::Square2D; break; + case TokenKind::Circle: node.kind = PrimitiveNode::Kind::Circle2D; break; + case TokenKind::Polygon: node.kind = PrimitiveNode::Kind::Polygon2D; break; default: break; } @@ -602,6 +612,54 @@ AstNodePtr Parser::parseModuleCall() { return makeModuleCall(std::move(node)); } +// --------------------------------------------------------------------------- +// Extrusions — linear_extrude / rotate_extrude +// --------------------------------------------------------------------------- +AstNodePtr Parser::parseExtrusion(TokenKind k) { + const Token& kw = advance(); + ExtrusionNode node; + node.loc = kw.loc; + node.kind = (k == TokenKind::LinearExtrude) ? ExtrusionNode::Kind::Linear + : ExtrusionNode::Kind::Rotate; + + expect(TokenKind::LParen, "expected '(' after extrude keyword"); + parseExtrusionParams(node.params); + expect(TokenKind::RParen, "expected ')' after extrude params"); + + node.children = parseBody(); + return makeExtrusion(std::move(node)); +} + +// Parses key=value params for linear/rotate_extrude. Unlike parseParamList, +// all values are kept as ExprPtr (including center and scale vectors). +void Parser::parseExtrusionParams(std::unordered_map& params) { + while (!check(TokenKind::RParen) && !atEnd()) { + const size_t prevPos = m_pos; + + if (check(TokenKind::SpecialVar)) { + std::string name = peek().text; + advance(); + expect(TokenKind::Equals, "expected '='"); + params[name] = parseExpr(); + match(TokenKind::Comma); + continue; + } + if (check(TokenKind::Ident) && peek(1).kind == TokenKind::Equals) { + std::string name = advance().text; + advance(); // consume '=' + params[name] = parseExpr(); + match(TokenKind::Comma); + continue; + } + // Positional scalar (height for linear_extrude) + if (!check(TokenKind::RParen)) { + params["_pos0"] = parseExpr(); + match(TokenKind::Comma); + } + if (m_pos == prevPos) break; + } +} + // --------------------------------------------------------------------------- // parseBody: { children* } or single child node // --------------------------------------------------------------------------- diff --git a/src/lang/Parser.h b/src/lang/Parser.h index c995e28..5c3fa9d 100644 --- a/src/lang/Parser.h +++ b/src/lang/Parser.h @@ -39,6 +39,7 @@ class Parser { AstNodePtr parseIf(); AstNodePtr parseFor(); AstNodePtr parseModuleCall(); + AstNodePtr parseExtrusion(TokenKind k); // ---- module definitions ----------------------------------------------- void parseModuleDef(ParseResult& result); @@ -52,6 +53,7 @@ class Parser { // ---- argument helpers ------------------------------------------------ void parseParamList(std::unordered_map& params, bool& center); + void parseExtrusionParams(std::unordered_map& params); // ---- child body ------------------------------------------------------ std::vector parseBody(); diff --git a/src/lang/Token.h b/src/lang/Token.h index 68789e4..d46eba1 100644 --- a/src/lang/Token.h +++ b/src/lang/Token.h @@ -52,6 +52,11 @@ enum class TokenKind : uint8_t { For, // for Module, // module + // 2-D primitives + Square, Circle, Polygon, + // Extrusion operations + LinearExtrude, RotateExtrude, + // Range separator Colon, // : diff --git a/tests/2d_extrude_test.scad b/tests/2d_extrude_test.scad new file mode 100644 index 0000000..b26e011 --- /dev/null +++ b/tests/2d_extrude_test.scad @@ -0,0 +1,60 @@ +// Tier 4 visual test — 2-D primitives and extrusions +// Open in ChiselCAD (and optionally OpenSCAD) to compare results. + +// Scene 1: linear_extrude a square +translate([0, 0, 0]) + linear_extrude(height=10) + square([8, 5]); + +// Scene 2: linear_extrude a circle → cylinder-like solid +translate([20, 0, 0]) + linear_extrude(height=15) + circle(r=4, $fn=32); + +// Scene 3: linear_extrude a triangle polygon +translate([40, 0, 0]) + linear_extrude(height=8) + polygon(points=[[0,0],[10,0],[5,9]]); + +// Scene 4: centered extrusion +translate([60, 0, 0]) + linear_extrude(height=10, center=true) + square([6, 6], center=true); + +// Scene 5: rotate_extrude of a circle offset from Z axis → torus +translate([0, 30, 0]) + rotate_extrude($fn=48) + translate([8, 0, 0]) + circle(r=2, $fn=24); + +// Scene 6: rotate_extrude partial sweep → C-shape +translate([25, 30, 0]) + rotate_extrude(angle=270, $fn=48) + translate([6, 0, 0]) + square([2, 4]); + +// Scene 7: linear_extrude with twist → helical prism +translate([50, 30, 0]) + linear_extrude(height=20, twist=90, $fn=24) + square([5, 5], center=true); + +// Scene 8: 2-D boolean inside extrude → hollow tube +translate([0, 65, 0]) + linear_extrude(height=12) + difference() { + circle(r=7, $fn=32); + circle(r=5, $fn=32); + } + +// Scene 9: polygon with paths (ring via two contours) +translate([25, 65, 0]) + linear_extrude(height=6) + polygon( + points=[[0,0],[8,0],[8,8],[0,8],[2,2],[6,2],[6,6],[2,6]], + paths=[[0,1,2,3],[4,5,6,7]] + ); + +// Scene 10: scale taper — frustum-like solid +translate([50, 65, 0]) + linear_extrude(height=12, scale=0.3) + circle(r=6, $fn=32); diff --git a/tests/test_lexer.cpp b/tests/test_lexer.cpp index 9ca0db1..ae8d05d 100644 --- a/tests/test_lexer.cpp +++ b/tests/test_lexer.cpp @@ -285,3 +285,23 @@ TEST_CASE("Lexer:Eof token is always last", "[lexer]") { auto tokens = lexer.tokenize(); REQUIRE(tokens.back().kind == TokenKind::Eof); } + +// --------------------------------------------------------------------------- +// 2-D primitives and extrusion keywords (Tier 4) +// --------------------------------------------------------------------------- +TEST_CASE("Lexer:2D primitive keywords", "[lexer]") { + REQUIRE(kinds("square") == std::vector{TokenKind::Square}); + REQUIRE(kinds("circle") == std::vector{TokenKind::Circle}); + REQUIRE(kinds("polygon") == std::vector{TokenKind::Polygon}); +} + +TEST_CASE("Lexer:extrusion keywords", "[lexer]") { + REQUIRE(kinds("linear_extrude") == std::vector{TokenKind::LinearExtrude}); + REQUIRE(kinds("rotate_extrude") == std::vector{TokenKind::RotateExtrude}); +} + +TEST_CASE("Lexer:linear_extrude with underscores tokenizes as single keyword", "[lexer]") { + auto t = lex("linear_extrude(height=10)"); + REQUIRE(t[0].kind == TokenKind::LinearExtrude); + REQUIRE(t[0].text == "linear_extrude"); +} diff --git a/tests/test_parser.cpp b/tests/test_parser.cpp index 7f0f10a..d456756 100644 --- a/tests/test_parser.cpp +++ b/tests/test_parser.cpp @@ -430,3 +430,85 @@ TEST_CASE("Parser:recovers from missing paren", "[parser]") { // Should still produce a node (partial parse) // The important thing is it doesn't crash or hang } + +// --------------------------------------------------------------------------- +// Tier 4 — 2-D primitives and extrusions +// --------------------------------------------------------------------------- +static const ExtrusionNode& asExtrude(const AstNodePtr& n) { + return std::get(*n); +} + +TEST_CASE("Parser:square positional vector", "[parser]") { + auto r = parse("square([10, 5]);"); + REQUIRE(r.roots.size() == 1); + const auto& p = asPrim(r.roots[0]); + REQUIRE(p.kind == PrimitiveNode::Kind::Square2D); + // Positional [10,5] → x=10, y=5 + REQUIRE(paramVal(p, "x") == Approx(10.0)); + REQUIRE(paramVal(p, "y") == Approx(5.0)); +} + +TEST_CASE("Parser:square named size scalar", "[parser]") { + auto r = parse("square(size=8);"); + REQUIRE(r.roots.size() == 1); + const auto& p = asPrim(r.roots[0]); + REQUIRE(p.kind == PrimitiveNode::Kind::Square2D); + REQUIRE(p.params.count("size") == 1); +} + +TEST_CASE("Parser:circle named r", "[parser]") { + auto r = parse("circle(r=5);"); + REQUIRE(r.roots.size() == 1); + const auto& p = asPrim(r.roots[0]); + REQUIRE(p.kind == PrimitiveNode::Kind::Circle2D); + REQUIRE(paramVal(p, "r") == Approx(5.0)); +} + +TEST_CASE("Parser:circle positional radius", "[parser]") { + auto r = parse("circle(3);"); + const auto& p = asPrim(r.roots[0]); + REQUIRE(p.kind == PrimitiveNode::Kind::Circle2D); + REQUIRE(paramVal(p, "_pos0") == Approx(3.0)); +} + +TEST_CASE("Parser:polygon with points", "[parser]") { + auto r = parse("polygon(points=[[0,0],[10,0],[5,8]]);"); + REQUIRE(r.roots.size() == 1); + const auto& p = asPrim(r.roots[0]); + REQUIRE(p.kind == PrimitiveNode::Kind::Polygon2D); + REQUIRE(p.params.count("points") == 1); +} + +TEST_CASE("Parser:linear_extrude with height", "[parser]") { + auto r = parse("linear_extrude(height=10) { circle(r=5); }"); + REQUIRE(r.roots.size() == 1); + const auto& e = asExtrude(r.roots[0]); + REQUIRE(e.kind == ExtrusionNode::Kind::Linear); + REQUIRE(e.params.count("height") == 1); + REQUIRE(e.children.size() == 1); +} + +TEST_CASE("Parser:linear_extrude positional height", "[parser]") { + auto r = parse("linear_extrude(15) square([4,4]);"); + const auto& e = asExtrude(r.roots[0]); + REQUIRE(e.kind == ExtrusionNode::Kind::Linear); + REQUIRE(e.params.count("_pos0") == 1); + REQUIRE(e.children.size() == 1); +} + +TEST_CASE("Parser:rotate_extrude with angle", "[parser]") { + auto r = parse("rotate_extrude(angle=180) circle(r=3);"); + REQUIRE(r.roots.size() == 1); + const auto& e = asExtrude(r.roots[0]); + REQUIRE(e.kind == ExtrusionNode::Kind::Rotate); + REQUIRE(e.params.count("angle") == 1); + REQUIRE(e.children.size() == 1); +} + +TEST_CASE("Parser:linear_extrude with twist and center", "[parser]") { + auto r = parse("linear_extrude(height=20, twist=90, center=true) square([5,5]);"); + const auto& e = asExtrude(r.roots[0]); + REQUIRE(e.params.count("height") == 1); + REQUIRE(e.params.count("twist") == 1); + REQUIRE(e.params.count("center") == 1); +} From c7ed4bb6764186088fd4c592201dfe987b3eb051 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Fri, 24 Apr 2026 15:01:53 -0700 Subject: [PATCH 2/5] fix(parser): accept keyword tokens as named params in param lists 'scale' is a keyword (TokenKind::Scale) so 'scale=0.3' inside linear_extrude or any primitive param list was not being parsed as a named parameter. Fix both parseParamList and parseExtrusionParams to accept any token followed by '=' as a named parameter, matching OpenSCAD's behaviour where keywords are not reserved in param position. Co-Authored-By: Claude Sonnet 4.6 --- src/lang/Parser.cpp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/lang/Parser.cpp b/src/lang/Parser.cpp index 1d452bb..3b9aa1b 100644 --- a/src/lang/Parser.cpp +++ b/src/lang/Parser.cpp @@ -359,10 +359,11 @@ void Parser::parseParamList(std::unordered_map& params, continue; } - // Named param: ident = expr - if (check(TokenKind::Ident) && peek(1).kind == TokenKind::Equals) { + // Named param: any token (Ident or keyword like 'scale') followed by '=' + if (peek(1).kind == TokenKind::Equals && !peek().text.empty() && + !check(TokenKind::SpecialVar)) { std::string name = peek().text; - advance(); // ident + advance(); // name token advance(); // = if (name == "center") { @@ -644,7 +645,8 @@ void Parser::parseExtrusionParams(std::unordered_map& para match(TokenKind::Comma); continue; } - if (check(TokenKind::Ident) && peek(1).kind == TokenKind::Equals) { + // Accept any token (Ident or keyword like 'scale') when followed by '=' + if (peek(1).kind == TokenKind::Equals && !peek().text.empty()) { std::string name = advance().text; advance(); // consume '=' params[name] = parseExpr(); From f9157465bf6fa44f1a31f66b01bd60d14010067c Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Fri, 24 Apr 2026 15:09:59 -0700 Subject: [PATCH 3/5] fix(extrude): negate twist to match OpenSCAD left-hand rule OpenSCAD documents that twist follows the left-hand rule (positive twist is clockwise when viewed from above). Manifold::Extrude uses the standard right-hand rule (positive = counter-clockwise). Negate the twist value so the two renderers agree. Co-Authored-By: Claude Sonnet 4.6 --- src/csg/MeshEvaluator.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/csg/MeshEvaluator.cpp b/src/csg/MeshEvaluator.cpp index 761f471..ec63736 100644 --- a/src/csg/MeshEvaluator.cpp +++ b/src/csg/MeshEvaluator.cpp @@ -204,7 +204,7 @@ manifold::Manifold MeshEvaluator::evalExtrusion(const CsgExtrusion& e, if (e.kind == CsgExtrusion::Kind::Linear) { double height = getP("height", getP("h", getP("_pos0", 1.0))); - double twist = getP("twist", 0.0); + double twist = -getP("twist", 0.0); // OpenSCAD uses left-hand rule; Manifold uses right-hand float scaleX = static_cast(getP("scale_x", 1.0)); float scaleY = static_cast(getP("scale_y", 1.0)); double fnOvr = getP("$fn", 0.0); From a901cfd417ef1eb99291462fd0bb87371cf0819b Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Fri, 24 Apr 2026 15:20:31 -0700 Subject: [PATCH 4/5] fix(extrude): only use nDivisions for twist, not plain scale taper Scale-only extrusions (e.g. frustums) work correctly with nDivisions=0 since Manifold linearly interpolates vertex positions from bottom to top. Intermediate divisions are only needed for twist to smoothly interpolate the rotation, so restrict the division count to the twist != 0 case. Co-Authored-By: Claude Sonnet 4.6 --- src/csg/MeshEvaluator.cpp | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/csg/MeshEvaluator.cpp b/src/csg/MeshEvaluator.cpp index ec63736..4e658db 100644 --- a/src/csg/MeshEvaluator.cpp +++ b/src/csg/MeshEvaluator.cpp @@ -208,7 +208,9 @@ manifold::Manifold MeshEvaluator::evalExtrusion(const CsgExtrusion& e, float scaleX = static_cast(getP("scale_x", 1.0)); float scaleY = static_cast(getP("scale_y", 1.0)); double fnOvr = getP("$fn", 0.0); - int nDivs = (twist != 0.0 || scaleX != 1.0f || scaleY != 1.0f) + // Divisions are only needed for twist (to smoothly interpolate the + // rotation). A plain scale taper works correctly with 0 divisions. + int nDivs = (twist != 0.0) ? std::max(1, static_cast(fnOvr > 0 ? fnOvr : 10)) : 0; bool center = (getP("center", 0.0) != 0.0); From 3e105b01a8e2ed9bd1458a53c82c1546f1528942 Mon Sep 17 00:00:00 2001 From: chris lindsey Date: Fri, 24 Apr 2026 15:55:20 -0700 Subject: [PATCH 5/5] fix(polygon): use EvenOdd fill rule to match OpenSCAD's polygon path semantics Nested same-winding contours (e.g. outer CCW square + inner CCW square) create holes in OpenSCAD because the inner region has an even crossing count. Manifold defaulted to FillRule::Positive (nonzero), making both contours fill solid. Switching to FillRule::EvenOdd reproduces the frame/ring shape OpenSCAD produces for polygon() with paths. Co-Authored-By: Claude Sonnet 4.6 --- src/csg/PrimitiveGen.cpp | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/csg/PrimitiveGen.cpp b/src/csg/PrimitiveGen.cpp index 7c96a18..11eaff6 100644 --- a/src/csg/PrimitiveGen.cpp +++ b/src/csg/PrimitiveGen.cpp @@ -217,7 +217,10 @@ manifold::CrossSection PrimitiveGen::generateCrossSection(const CsgLeaf& leaf) c contour.push_back({pt.x, pt.y}); if (!contour.empty()) polys.push_back(std::move(contour)); } - return polys.empty() ? manifold::CrossSection{} : manifold::CrossSection(polys); + // EvenOdd matches OpenSCAD's polygon fill rule: nested same-winding + // contours create holes (inner area has even crossing count → outside). + return polys.empty() ? manifold::CrossSection{} + : manifold::CrossSection(polys, manifold::CrossSection::FillRule::EvenOdd); } default: