From 69c6f86cd86492b7856bd7fe19dbefb8b7c9da75 Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Sun, 13 Sep 2026 13:30:39 -0700 Subject: [PATCH 1/2] Cache key: a font handle does not identify a font text() put the resolved FontHandle in its CSG params. A handle numbers faces WITHIN one FontProvider, and every Evaluator makes its own, so two evaluators sharing a ManifoldCache -- which is exactly what two GUI tabs are -- handed the cache the same number for different fonts. The second render was served the first one's glyphs. Measured, not deduced: two providers, and providerA.resolveFont("Arial:style=Regular") -> 4 providerB.resolveFont("Arial:style=Bold") -> 4 End to end through one shared cache, before: Arial Regular 204 tris width 9.196 Arial Bold 204 tris width 9.196 <- Regular's glyph Courier Regular 204 tris width 9.196 <- Regular's glyph Courier Bold 204 tris width 9.196 <- Regular's glyph after, each matches what it renders in a cache of its own (204/9.196, 164/9.298, 320/6.978, 296/7.819). The fix is the SPEC alongside the handle in the params, so the key tells them apart. The handle still does the lookup within its own evaluator. Two specs that resolve to one face merely miss the cache rather than collide, which is the safe direction. Tests here pin the two properties that are provable here: the handle collision across providers, and cacheKey distinguishing two nodes that differ only by spec. The end-to-end regression test lives in BelfrySCAD -- written against these C++ helpers the scenario does not collide, so a test here would have passed either way, and a green test that cannot fail is worse than none. Reported as BelfrySCAD#434. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JFTVMTH6yEktHDPF5csksb --- pyproject.toml | 2 +- src/builtins/text.cpp | 10 ++++++++++ tests/test_manifold_cache.cpp | 22 ++++++++++++++++++++++ tests/test_text.cpp | 22 ++++++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 16a0fae..4c3effd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "scikit_build_core.build" [project] name = "openscad_cpp_evaluator" -version = "1.20.2" +version = "1.20.3" description = "C++ OpenSCAD evaluator with Python bindings" readme = "README.md" requires-python = ">=3.12" diff --git a/src/builtins/text.cpp b/src/builtins/text.cpp index 4a38e22..0f53f99 100644 --- a/src/builtins/text.cpp +++ b/src/builtins/text.cpp @@ -70,6 +70,16 @@ CSGParams resolveText(Evaluator& ev, const oscad::ModularCall& node, EvalContext } CSGParams params; + // The handle is what generateText looks the face up by, but it is an + // index into THIS evaluator's FontProvider -- the first font resolved + // is handle 0 whatever it is. Two evaluators sharing a ManifoldCache + // (two tabs in a GUI, say) therefore agreed on "font_handle=0" for + // entirely different fonts, and the second render was served the + // first one's glyphs. The spec goes in the params too, so the cache + // key tells them apart: the same spec always resolves to the same + // face, and two specs that happen to resolve alike merely miss the + // cache rather than collide. + params["font_spec"] = Value{fontSpec}; params["font_handle"] = Value{static_cast(handle)}; params["scale"] = Value{scale}; params["segs"] = Value{static_cast(segs)}; diff --git a/tests/test_manifold_cache.cpp b/tests/test_manifold_cache.cpp index fa57cbd..8d1f59d 100644 --- a/tests/test_manifold_cache.cpp +++ b/tests/test_manifold_cache.cpp @@ -36,6 +36,28 @@ TEST(ManifoldCacheKey, BoolAndNumberParamsProduceDifferentCacheKeys) { EXPECT_NE(cacheKey(a), cacheKey(b)); } +TEST(ManifoldCacheKey, TwoFontsWithTheSameHandleDoNotShareAKey) { + // A FontHandle indexes the EVALUATOR's own FontProvider, so the first + // font any evaluator resolves is handle 0 whatever it is. Two + // evaluators sharing one ManifoldCache -- two tabs in a GUI -- agreed + // on "font_handle=0" for entirely different fonts, and the second was + // served the first one's glyphs. text() puts the SPEC in the params + // too, so the key tells them apart. + CSGNode a; + a.kind = "text"; + a.isBuiltin = true; + a.params["font_handle"] = Value{0.0}; + a.params["font_spec"] = Value{std::string("Arial:style=Regular")}; + + CSGNode b; + b.kind = "text"; + b.isBuiltin = true; + b.params["font_handle"] = Value{0.0}; // same handle, different font + b.params["font_spec"] = Value{std::string("Arial:style=Bold")}; + + EXPECT_NE(cacheKey(a), cacheKey(b)); +} + TEST(ManifoldCacheKey, IdenticalStructureProducesIdenticalKeyRegardlessOfParamInsertionOrder) { CSGNode a; a.kind = "cube"; diff --git a/tests/test_text.cpp b/tests/test_text.cpp index 984f160..b75284e 100644 --- a/tests/test_text.cpp +++ b/tests/test_text.cpp @@ -422,3 +422,25 @@ TEST(TextArgs, FontIsPositionalButTheRestAreNot) { // NOTHING, and halign by name does move it. EXPECT_EQ(echoes[0], "ECHO: true, false, true, false"); } + +namespace oscadeval { + +// Why text() must put the font SPEC in its CSG params and not just the +// handle: a handle numbers faces WITHIN one provider, and every Evaluator +// makes its own. Two evaluators sharing a ManifoldCache -- two GUI tabs -- +// hand the cache the same number for different fonts (BelfrySCAD#434). +TEST(FontHandles, AreOnlyMeaningfulWithinOneProvider) { + FreetypeFontProvider a, b; + const FontHandle aReg = a.resolveFont("Arial:style=Regular"); + const FontHandle aBold = a.resolveFont("Arial:style=Bold"); + const FontHandle bBold = b.resolveFont("Arial:style=Bold"); + if (aReg == aBold) { + GTEST_SKIP() << "this machine resolves both specs to one face"; + } + EXPECT_NE(aReg, aBold) << "within one provider, two fonts get two handles"; + EXPECT_EQ(aReg, bBold) + << "across providers the numbering restarts, so a handle alone " + "cannot identify a font to a shared cache"; +} + +} // namespace oscadeval From a5163e17e968e21cf31b42d74cb85576e8bba263 Mon Sep 17 00:00:00 2001 From: Revar Desmera Date: Sun, 13 Sep 2026 13:54:24 -0700 Subject: [PATCH 2/2] Don't assert how a SECOND provider numbers its faces The test claimed a fresh provider gives the first font it resolves the same handle the previous provider gave ITS first font. That is what macOS does (both 4, which is the collision this change is about) but not what the Linux runner does: with only the bundled faces present the numbering lines up instead, 0 and 1 either way, and the test failed there on aReg=0 vs bBold=1. What is universally true is the part that stays: within ONE provider two fonts get two handles. That a second provider CAN reuse a number is the motivation, is recorded as a comment with both platforms' numbers, and is covered end to end by BelfrySCAD's own test -- where the collision reproduces in both directions rather than depending on which fonts the machine has. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JFTVMTH6yEktHDPF5csksb --- tests/test_text.cpp | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/test_text.cpp b/tests/test_text.cpp index b75284e..1f7b8ff 100644 --- a/tests/test_text.cpp +++ b/tests/test_text.cpp @@ -433,14 +433,19 @@ TEST(FontHandles, AreOnlyMeaningfulWithinOneProvider) { FreetypeFontProvider a, b; const FontHandle aReg = a.resolveFont("Arial:style=Regular"); const FontHandle aBold = a.resolveFont("Arial:style=Bold"); - const FontHandle bBold = b.resolveFont("Arial:style=Bold"); if (aReg == aBold) { GTEST_SKIP() << "this machine resolves both specs to one face"; } EXPECT_NE(aReg, aBold) << "within one provider, two fonts get two handles"; - EXPECT_EQ(aReg, bBold) - << "across providers the numbering restarts, so a handle alone " - "cannot identify a font to a shared cache"; + // What a second provider numbers them is NOT asserted: it depends on + // which faces that machine has and the order they are opened in. On + // macOS a fresh provider resolving Bold first returns 4 -- the same + // number the first provider gave REGULAR, which is the collision this + // whole change is about. On a Linux runner with only the bundled faces + // the numbering happens to line up instead (0 and 1 either way). That + // it CAN collide is the point, and it is not a property to pin to a + // number here; the end-to-end regression test lives in BelfrySCAD, + // where the collision reproduces in both directions. } } // namespace oscadeval