M6: Variables & equations — parametric expressions driving feature dimensions

Add named document variables (CadDocument::variables) and per-feature
expression bindings (CadFeature::expr, field-name -> expression). On
recompute(), variables are evaluated topologically (cycle detection), then
each feature's expr entries are evaluated and written into its numeric fields
before geometry runs. Self-contained shunting-yard evaluator (+ - * /, parens,
unary minus, sqrt/abs/sin/cos/tan(deg)/min/max, pi). assign_field allow-lists
the 33 dimension fields + pattern_count; unknown names error loudly.

Additive: recipe stays v2 (fields appended to both cereal lists, golden
fixture regenerated). MCP set_variable / set_feature_expr are pure additions.
Suite 95 cases / 1440 assertions green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
This commit is contained in:
Tommaso Bianchi
2026-07-25 08:11:06 +02:00
co-authored by Claude Opus 4.8
parent aa30575369
commit 15ea0a813f
5 changed files with 485 additions and 3 deletions
+156
View File
@@ -4376,3 +4376,159 @@ TEST_CASE("hole: round-trip preserves styled counterbore hole", "[CadDocument][h
}
REQUIRE(found);
}
TEST_CASE("variables drive a box (parametric sketch + extrude)", "[CadDocument][variables]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
20, 20, 10, "Sketch");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
REQUIRE(doc.features.size() >= 2);
int ex = sk + 1;
doc.variables = {{"w", "10"}, {"h", "w*2"}};
doc.features[sk].expr = {{"width", "w"}, {"height", "w"}};
doc.features[ex].expr = {{"distance", "h"}};
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE_FALSE(doc.body.IsNull());
Bnd_Box bb; BRepBndLib::Add(doc.body, bb);
double xlo, ylo, zlo, xhi, yhi, zhi;
bb.Get(xlo, ylo, zlo, xhi, yhi, zhi);
REQUIRE_THAT(xhi - xlo, WithinAbs(10.0, 1.0));
REQUIRE_THAT(yhi - ylo, WithinAbs(10.0, 1.0));
REQUIRE_THAT(zhi - zlo, WithinAbs(20.0, 2.0));
}
TEST_CASE("function + pi in expression binding", "[CadDocument][variables]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Circle, SketchPlane::XY(),
20, 20, 10, "Sketch");
doc.add_extrude(sk, 5.0, false, BooleanMode::New, "Extrude");
REQUIRE(doc.features.size() >= 2);
doc.variables = {{"r", "sqrt(16)+abs(-2)"}};
doc.features[sk].expr = {{"radius", "r"}};
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE_THAT(doc.features[sk].radius, WithinAbs(6.0, 1e-6));
doc.variables = {{"r", "max(3, pi)"}};
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE_THAT(doc.features[sk].radius, WithinAbs(M_PI, 1e-6));
doc.variables = {{"r", "5"}, {"d", "r*2"}};
doc.features[sk].expr = {{"radius", "d"}};
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
REQUIRE_THAT(doc.features[sk].radius, WithinAbs(10.0, 1e-6));
}
TEST_CASE("cycle detected fails recompute with error", "[CadDocument][variables]")
{
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
20, 20, 10, "Sketch");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
doc.features[sk].expr = {{"width", "a"}};
doc.variables = {{"a", "b"}, {"b", "a"}};
REQUIRE_FALSE(doc.recompute());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("cycle"));
}
TEST_CASE("unknown parameter fails recompute", "[CadDocument][variables]")
{
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
20, 20, 10, "Sketch");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
doc.features[sk].expr = {{"nope", "1"}};
REQUIRE_FALSE(doc.recompute());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("unknown parameter"));
}
TEST_CASE("unknown identifier fails recompute", "[CadDocument][variables]")
{
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
20, 20, 10, "Sketch");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
doc.features[sk].expr = {{"width", "x"}};
doc.variables = {{"x", "y+1"}};
REQUIRE_FALSE(doc.recompute());
REQUIRE_THAT(doc.error, Catch::Matchers::Contains("unknown identifier"));
}
TEST_CASE("parametric recipe round-trips through serialize/deserialize", "[CadDocument][variables]")
{
using Catch::Matchers::WithinAbs;
CadDocument doc;
int sk = doc.add_sketch(SketchShape::Rectangle, SketchPlane::XY(),
20, 20, 10, "Sketch");
doc.add_extrude(sk, 10.0, false, BooleanMode::New, "Extrude");
REQUIRE(doc.features.size() >= 2);
int ex = sk + 1;
doc.variables = {{"w", "10"}, {"h", "w*2"}};
doc.features[sk].expr = {{"width", "w"}, {"height", "w"}};
doc.features[ex].expr = {{"distance", "h"}};
REQUIRE(doc.recompute());
REQUIRE(doc.error.empty());
Bnd_Box bb_orig; BRepBndLib::Add(doc.body, bb_orig);
double ox0, oy0, oz0, ox1, oy1, oz1;
bb_orig.Get(ox0, oy0, oz0, ox1, oy1, oz1);
auto blob = doc.serialize_recipe();
REQUIRE_FALSE(blob.empty());
CadDocument doc2;
REQUIRE(doc2.deserialize_recipe(blob));
REQUIRE(doc2.variables.size() == 2);
REQUIRE(doc2.variables["w"] == "10");
REQUIRE(doc2.variables["h"] == "w*2");
bool found_sk = false, found_ex = false;
for (const auto& f : doc2.features) {
if (f.name == "Sketch") {
REQUIRE(f.expr.size() == 2);
REQUIRE(f.expr.at("width") == "w");
REQUIRE(f.expr.at("height") == "w");
found_sk = true;
}
if (f.name == "Extrude") {
REQUIRE(f.expr.size() == 1);
REQUIRE(f.expr.at("distance") == "h");
found_ex = true;
}
}
REQUIRE(found_sk);
REQUIRE(found_ex);
REQUIRE(doc2.bodies.size() == doc.bodies.size());
Bnd_Box bb2; BRepBndLib::Add(doc2.body, bb2);
double nx0, ny0, nz0, nx1, ny1, nz1;
bb2.Get(nx0, ny0, nz0, nx1, ny1, nz1);
REQUIRE_THAT(nx0, WithinAbs(ox0, 1e-6));
REQUIRE_THAT(ny0, WithinAbs(oy0, 1e-6));
REQUIRE_THAT(nz0, WithinAbs(oz0, 1e-6));
REQUIRE_THAT(nx1, WithinAbs(ox1, 1e-6));
REQUIRE_THAT(ny1, WithinAbs(oy1, 1e-6));
REQUIRE_THAT(nz1, WithinAbs(oz1, 1e-6));
}