BCad fork

Timeline
Login

Timeline

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

50 most recent check-ins

2026-10-03
19:39
Grant the Linux native libraries hidden visibility and --as-needed, add DT_RELR, and give OCCT a $ORIGIN install RPATH.

The pythonocc wrappers build with -fvisibility=hidden, which leaves only PyInit* exported and shrinks their dynamic symbol and relocation tables. Linking them with -Wl,--as-needed drops the NEEDED entries for the OCCT toolkits a wrapper does not reference directly (16 of them). -Wl,-z,pack-relative-relocs emits DT_RELR on both pythonocc and OCCT; it pays off on the wrappers, whose relocations are local, but not on OCCT, whose cross-references are preemptible and which therefore has no relative relocations to pack.

--as-needed also exposed that RUNPATH does not propagate to transitive dependencies: once the wrappers stop listing the whole OCCT toolkit set, OCCT-to-OCCT links must be resolved from the installed libraries' own RPATH, and the install left it empty. Set OCCT's CMAKE_INSTALL_RPATH to '$ORIGIN:$ORIGIN/../../freetype/lib' so the installed libraries find one another and the venv freetype (linked by TKService and TKV3d). The packaged distributions never hit this - the PyInstaller bootloader flattens everything and sets LD_LIBRARY_PATH - but running from the source venv did. leaf check-in: 4b8b39fa60 user: johnfound tags: object-expression

17:38
Build OCCT with -O2 and drop the build-variant knobs

OCCT was parameterized with OCCT_OPT/OCCT_TBB/OCCT_BUILD_DIR/OCCT_INSTALL to compare -Os against -O2 out of tree. The comparison is settled: -O2 cuts evaluate by about 27% for a roughly 26% larger library, so make it the only build. Hardcode -O2 -DNDEBUG, USE_TBB=OFF (TBB measured no gain and adds a system dependency), the default build directory and the venv install prefix, and drop the OCCT_JOBS override in favour of a plain $(nproc).

Do the same on Windows: -O2 and an explicit -DUSE_TBB=OFF, so a future OCCT default of USE_TBB=ON cannot silently turn TBB on.

Upgrade pip through python -m pip on both platforms: the Windows pip.exe launcher cannot replace itself and aborts with a message asking to run python.exe -m pip install --upgrade pip.

pythonocc and the other native build scripts keep -Os; the hot code lives in OCCT, so their optimisation level does not matter. check-in: bbf6f83894 user: johnfound tags: object-expression

11:11
Read the old face hashes in traversal order when remapping colors

A color map is not always built in face traversal order: the boolean and offset mappers insert keys in the order they assign them and append the faces they left alone afterwards. transform_shape/transform_gtrsf remapped the colors by zipping that insertion order with face_hashes(new_shape) (face traversal order), so a transform of a colored boolean/offset/minkowski result shuffled the colors among the faces. A no-op translate([0,0,0]) still takes the transform path, so it triggered the bug too.

Read the old hashes with face_hashes(shape) in traversal order instead. The disk cache rebuilds a map in traversal order on load, which is why the bug only showed with the cache disabled. Add a mixed-color regression test to tests/test_transform_colors.py. check-in: 17678647a7 user: johnfound tags: object-expression

11:05
Stamp the cache key on the primitive itself, not on every child

cube/cylinder/sphere/polyhedron add their part straight into the active context, so the handlers could not tell which child they had just made. They used to stamp _cache_key on every child that had a shape, which overwrote the key of a node that was already there.

A minkowski() whose base is followed by a bare sphere() therefore hashed both children to the sphere's key: two calls with the same sphere and different bases collided, and the cache served the first result for both.

Set _cache_key in the primitive constructors instead, and add tests/test_cache_keys.py as a regression test. check-in: 1016568050 user: johnfound tags: object-expression

2026-10-02
23:14
Fix twist sweep performance: correct GeomFill_GuideTrihedronAC::D2

OCCT #1182 enabled the second derivative of the guide trihedron (return Standard_False -> true), so its values became C2 constraints of Approx_SweepApproximation. D2 computed TN2 as TN.SquareMagnitude() (always 1, since TN is normalized) instead of DTN.SquareMagnitude(); the error is exactly |dTN|^2 - 1 and blew up the pole count of the swept surface at large twist. With the fix the finite-difference residual drops from 23.5 to 1e-8, the twist pole count from 1036/259 to 48/12, and scl_examples/thread.scad evaluate from 18.7s/722MB to 2.0s/285MB. Record the OCCT patch under cpp/occt-patches/. check-in: 32210b8483 user: johnfound tags: object-expression

21:59
Fix boolean result orientation: ConcatBSplines=False in UnifySameDomain

_unify_once hardcoded the 4th ShapeUpgrade_UnifySameDomain argument (ConcatBSplines=True), contrary to the OCCT default and to surface(). On some fused bodies (a tilted cylinder fused into a torus) it silently inverts the solid: the unified result has a negative volume and fails BRepCheck, while the raw Fuse is valid. Pass ConcatBSplines=False.

Reproduced on stock upstream OCCT 7.9.3 as well, so it is not specific to the patched build. Add tests/test_bool_union_orientation.scad. check-in: c547d7fc31 user: johnfound tags: object-expression

21:38
Collapse difference() into one multi-tool Cut per base part

difference() used to subtract each cutter child in a separate 2-way BRepAlgoAPI_Cut, re-unpacking the running result after every step. All cutter parts are now collected once and each base part is cut by all overlapping tools in a single variadic Cut. Tools are passed as separate SetTools elements -- a compound tool makes Cut silently return wrong geometry on overlapping tools, so that form is never used.

The sequential loop inside _cut_parts is gone: it now takes the flat tool list and runs one variadic op per base part. The duplicate single-part / multi-part finalisation paths in SCLDifference.difference are merged.

Measured on the perf stand (evaluate phase, median): bool_difference_holes 79 -> 51 ms, colors_bool 191 -> 139 ms (-27..-37% of the boolean phase); STL/STEP unchanged, geometry guard (volume, faces, validity, face colors) identical. Add tests/test_bool_difference.scad covering overlapping cutters, a compound cutter child, a compound union() base, a base that splits into disconnected parts, the bbox prefilter, and a fully removed base. check-in: 7b29ff2089 user: johnfound tags: object-expression

20:34
Fix UnifySameDomain losing a spherical cap split by a sphere seam

ShapeUpgrade_UnifySameDomain with UnifyFaces=True merges the two halves of a cap that the seam of a periodic sphere splits into one face with zero area, losing the surface: the solid becomes invalid, the volume drops and the bounding box blows up. This reproduces on stock OCCT as well.

Detect the artefact (_unify_left_degenerate_face: an edged face whose outer wire has fewer than three edges and whose area is zero) and redo the unification with UnifyFaces=False, which keeps the edge cleanup without merging faces. A face left with no edges at all is a different artefact (a legitimate closed surface); ShapeFix still restores its seam.

Run the unification on a BRepBuilderAPI_Copy: Build() mutates its input in place (SetSafeInputMode does not prevent it), which also corrupted memoized shared primitives.

Add tests/test_unify_degenerate.scad. check-in: 52ef49b9db user: johnfound tags: object-expression

13:22
Migrate to OCCT 8.0.1 / pythonocc-core 8.0.1

Viewer

- boccviewer.py: move `from OCC.Core.Visualization import Display3d` to the end of the OCC import block. GetView()/GetViewer()/GetContext()/GetCamera()/GetStructureManager() return handles owned by other SWIG modules; without their proxies registered pythonocc 8.0.1 hands back a bare SwigPyObject and every method call on the view fails with AttributeError. Importing afterwards does not repair an already-returned object, so the order is load-bearing.

- Replace Handle_Graphic3d_TextureEnv_Create() with SetTextureEnv(None): pythonocc 8.0.1 no longer generates the Handle_*_Create helpers, and a null handle is accepted.

- Drop the dead V3d_View.Tumble call (removed upstream, never reached in bcad).

- Document the import order in AGENTS.md together with its symptom.

Patches and build

- Rebase all six OCCT patches onto V8_0_1. None could be dropped; each is rebased and modernised rather than copied, so upstream's own 8.0 modernisation survives: Standard_Max/Min/Abs/Sin/Cos/ATan2 -> std::, array-out-parameter Knots/Poles/Weights/Multiplicities -> const references or pointers, TopTools_IndexedDataMapOfShapeListOfShape -> NCollection_IndexedDataMap with TopTools_ShapeMapHasher. OCCT's alias headers under src/Deprecated/NCollectionAliases/ must not be included from OCCT sources.

- Pin OCCT V8_0_1 and pythonocc-core 8.0.1 in the linux and windows build scripts; lift the swig cap (pythonocc 8.0.1 accepts 4.2.1..4.5.1) and pin swig==4.5.0.

- Make the OCCT build job count overridable via OCCT_JOBS; OCCT reaches the OOM killer on a 6 GB machine at full parallelism.

- Record the migration in docs/cpp-patches.md.

Tests

- test_step_export.scad: added; it existed only as an untracked file and used a capture() builtin that bcad never had.

- test_step_perface.scad, test_stl_resolution.scad: export() is a statement (phase 2) but `x = import(...)` is an assignment (phase 1), so in one scope every import bound BEFORE the export that writes the file it read. All three tests could only pass with a file left over from an earlier run: test_stl_resolution advanced by one block per run and needed four warm-ups. Imports and checks now live in check(), called after the exports, so each test passes from a clean /tmp in a single run. No assertion was dropped or weakened; the STL face counts (236/252/1596/1256/888) are unchanged.

85/85 .scad tests and 39/39 unittests pass from a cold start; the migration introduces no regression. check-in: db4344ad7e user: johnfound tags: object-expression

2026-10-01
21:45
The mirror chain reads as broken in the docs and in the cone test, and is not

7a380af1 rebuilt the image frame of an elementary surface from the image axis and the negated U, and that settles the chain 3b0ea19de had left open as the next piece of work: a general transform over mirrored geometry that also carries a rotation. Nothing in the repository said so, and two places said the opposite.

The docs paragraph described the old measurement - the wrong volume, the wrong centroid - and stopped there, so it read as a description of a defect rather than of a case that works. tests/test_scale_cone_asymmetric.scad went further and stated in the present tense that the case 'comes back wrong', concluded that no arrangement of the general transform avoids it, and then deliberately asserted only the part that held - mirror in front of a scale, no rotation in between. That conclusion was measured wrong: the baseline it was measured on was 3b0ea19de, a descendant of the revision it named, and the chain on that same baseline gives is_valid false with 3612.83 against 785.40.

History is what the repository is for. The prose and the assertions have to describe the current state of things, so the paragraph now states the rule and what follows from it - the flags come from the parameter map, not from the order the transforms arrive in, so a rigid transform between the mirror and the general one changes nothing - and gives the numbers, 250*pi = 785.40 with the centroid at [1.875, 0, 0], pointing at section 8 of tests/test_mirror_gtrsf_validity.scad. The cone test gets the full assertion back: is_valid, volume and centroid for scale([0.5,1,1]) rotate([0,90,0]) mirror([1,0,0]).

The restored assertion has teeth - on the OCCT built from the parent of 7a380af1 the same chain is an invalid solid of 3612.83 centred at [3.60, 0, -0.78], so it fails on its first line there.

10 .scad tests and 39 python unittests green. check-in: d519086b7c user: johnfound tags: object-expression

21:36
A mirrored primitive has to be rebuilt as an image, not as a right handed frame

scale(1) mirror([1,0,0]) sphere(10) came out with is_valid() == false - a sphere that a mirror cannot break, produced by scaling by one. Nothing warned; the shape was just a shell with the wrong sense.

Where it came from. A gp_Trsf mirror is rigid, so it leaves the geometry where it was and the surface arrives at BRepTools_GTrsfModification carrying a left handed frame: Geom_XXXSurface::Copy(Trsf) stores the linear image of the frame as it comes out of the transform, with no way to say a frame has no right hand. The elementary branch then rebuilt the image frame as a gp_Ax3, which is right handed by construction, so a mirrored surface was silently rewritten - and with it the sense of the UV walk of every wire on the face. BRepTopAdaptor_FClass2d reads that sense from the stored orientation, puts the infinite point inside the wire, and BRepCheck answers BadOrientationOfSubshape.

The fix is not to reverse the face and hope. gp_Ax3 has no left handed frame, so the only orientation preserving way to write the image down is to keep the image axis as the main direction and negate the source U - the parameter map becomes diag(sigmaImg, vScale) with sigmaImg = sign(zdir . normal) and vScale > 0. Two independent flags come out of that map, and BRepTools_Modifier keeps them as separate locals of the sub-shape it rebuilds, so both are needed: RevWires (sigmaImg < 0) reverses the wires inside the face, because the map has negative determinant and the sense in the (u,v) plane flips; RevFace (leftSource) orients the face, because the rebuilt frame is right handed while the source frame was not. RevWires alone is the missing flag in the old code - without it the face itself was fine and only its wires were wrong.

V cannot take the flip instead, and for the cone not even in principle: the generator has an axial component next to the radial one, so a mirrored cone cannot be described by negating V at all. Its semi-angle is rebuilt from the generator image, ATan2(sx*sin(alpha), sz*cos(alpha)). The complementary angle pi - alpha, which is what negating V would have produced, is outside the OCCT convention anyway - OCCT wants alpha in (0, pi/2) and throws Standard_ConstructionError on the cone rather than on the parameter.

Both flags together give s*w = sign(det myGTrsf), the same value the plane and the generic B-spline branches already produce, which is what keeps the faces of a mixed shell - planes next to elementary surfaces - consistent with each other instead of merely self consistent.

It also closes the chain that 3b0ea19de left open, where a rigid transform stands between the mirror and the general one and the cone comes back wrong in volume and centroid, not only in validity: scale([0.5,1,1]) rotate([0,90,0]) mirror([1,0,0]) on a cone measured 3612.83 against 785.40 with its centroid at [3.60, 0, -0.78] against [1.875, 0, 0]. It measures 785.398 at [1.875, 0, 0] now. That was measured, not argued: the patch was reverted from the checkout, TKBRep and TKTopAlgo rebuilt and installed, the four probes run, then the patch put back and rebuilt again.

Not covered, and out of scope: an image that is no longer the same kind of surface takes the BSpline paths, which are exact in shape but carry the deflection error of that representation - a sphere under a non-uniform scale measures 2093.48 against 2094.40, 0.04%. The test asserts validity there and volume only where the image stays elementary.

Tests: tests/test_mirror_gtrsf_validity.scad, new, 8 sections. It fails on the old patch at line 58 (the reported case) and its last section is the chain above. It covers mirror()/multmatrix()/resize(), both orders of mirror and general transform, uniform and anisotropic scales, negative determinants, elementary and non-elementary images, and mixed shells as solids. 15 .scad tests and 39 python unittests green.

Docs: the image axis / negated U rule and the two flags in cpp/occt-patches/README.md and docs/cpp-patches.md. check-in: 7a380af107 user: johnfound tags: object-expression

14:30
A general transform has to move the position too, not only the figure

A location is not part of the geometry, it is a rigid transform waiting to be applied, so a point of a located shape sits at L(p) where p is its position in the geometry. Scaling such a shape has to move that point to A(L(p)) and not to L(A(p)): a location says where the figure is, and the distances around the origin belong to the scale. It did the latter. scale(0.5) translate([10,0,0]) sphere(10) gave a radius-5 sphere centred at [10,0,0] instead of [5,0,0] - right size, wrong place, no warning. multmatrix and resize reach the same place and were wrong the same way.

Where it came from. e198eb1 made the rigid path cheap by keeping the location instead of rebuilding the geometry, and handled the meeting of the two kinds of transform by taking the location off, applying the general transform to the bare geometry and putting the location back unchanged. That is L(A(p)) written out. The cone it was guarding came out valid and at the right volume, so the mistake did not look like a broken solid.

The fix folds the location into the general transform. A location is a gp_Trsf and a general transform is affine, so A o L is affine and fits in a gp_GTrsf just as well: the location becomes a gp_GTrsf through SetTrsf, it multiplies into the vectorial and translation parts of the incoming one, and OCCT is handed a single transform over plain geometry - the one thing it computes correctly. Nothing was rolled back to reach it. Reverting the cheap rigid path would have given the right answer and given back the cost it was bought for, in the case a model hits most: a loop placing scaled copies. The fast path is unchanged by construction, since a shape with no location skips the composition entirely, and where the composition does run a rigid chain followed by a general transform measures 880 us/iter here against 977 on e198eb1, so it is not a cost.

What the tests found. tests/test_transform_position.scad (new, 11 sections) covers scale, multmatrix, resize, and a rigid transform after a general one, all through centroid() - BRepBndLib over-estimates the bounding box of a rational B-spline, and a non-uniform scale turns a sphere or a cone into one, so the integral is what stays exact. It fails on e198eb1's code and on 623d69's, which is what makes it a test of the two arrangements rather than of the shape.

tests/test_scale_cone_asymmetric.scad: the chain assertion loses the mirror, and not because of this change. A mirror does not stay a location - OCCT rebuilds the geometry - and a general transform over mirrored geometry that has also been rotated comes back wrong in volume and centroid, not only in validity. A clean extract of 623d69, where a general transform went over the located shape directly, produces the same corrupt body, so no arrangement of the general transform avoids it; strip-and-restore masked it by never letting the geometry see the rotation. The fix belongs in how a mirror is represented: TopLoc_Location can hold one (Form=5, negative determinant) and the same chain is a valid 785.40 solid when it does, which is the next piece of work rather than this one. The rotation chain, which OCCT does get right, now also asserts the position - rotate y 90 maps (x,y,z) to (z,y,-x), so the cone's centroid goes [4,0,3.75] to [3.75,0,-4] and the scale halves x.

Grid: 73/75 .scad green, 39/39 python unittest green. The two failures - test_projection_outline.scad:85 and test_step_export.scad:18 - reproduce on a clean extract of 623d69 with these test files, so they are not from this change. Measurements of that baseline taken earlier in a scratch checkout were wrong: it held files from two revisions and a stale .shape_cache, and both moved once the cache was cleared. check-in: 3b0ea19de6 user: johnfound tags: object-expression

12:34
Speed up for-loop evaluation, fix the face-color regression it caused

A for iteration cost 2.1-2.8 ms, nearly all of it in work that had nothing to do with the loop: rebuilding a topology signature on every transform, remapping face colors by hash, and rebuilding identical primitives once per occurrence. Per-iteration numbers at 1000 iterations, us/iter:

cube 616 -> 112 sphere 788 -> 261 cube+translate 2845 -> 256 module 2266 -> 350 cube+rotate 2956 -> 269 nested 2597 -> 343 cube+scale 2937 -> 611 compr+geometry 2187 -> 239 cube+color 951 -> 269 extrude(cached) 2194 -> 790 cube+2translates 5301 -> 441 data only 33 -> 24

sum 6983 -> 3981. The machine is noisy (+-20%), so trust the ratios, not the absolute numbers. Benchmarks: test_code/bench_loops.py, new.

What it cost, and what it bought.

The signature of a transform's result is derived from its source, and both rigid and general transforms preserve it - a rigid one moves the TShape tree without touching surface kinds or counts, a general one reproduces it. So transform_shape no longer walks the faces to compare: has_display_topology() says whether the two are known to agree, and the caller's signature carries over when they do.

BRepBuilderAPI_Transform(copy=False) does not touch the geometry of its input, only the location, which makes a rigid transform on a shape someone else also holds safe. Audited every in-place mutation first: the ShapeFix calls only ever see freshly built results, and the three Reverse() calls in scl_context are all on local shapes.

Face colors are remapped by position rather than by hash. A copy and a located shape hash differently, but the correspondence old-face to new-face is 1:1 in traversal order, which is what the remap uses.

Primitives are memoized in memory only - a box is cheap to build and expensive to pickle, but a loop placing the same box a thousand times rebuilds it a thousand times, and reusing it is the whole cost of the loop. get_args_list is cached on id(args_definition) with the definition kept alive, so substitute_undef_with_defaults can return a copy of the default rather than a shared one.

BRepBuilderAPI_GTransform on a shape with a non-empty TopLoc_Location returns a corrupt body - a cone comes out at volume 312.80 instead of 790.51 and invalid - regardless of the Copy flag. _gtransform_moved() strips the location, transforms, and puts it back; composed locations are not unwound.

The regression, and the fix.

The lazy face-color map was the one that broke. linear_extrude and rotate_extrude hand SCLShape a merged_colors dict which is empty whenever the profile is uncolored - and an empty dict is not 'no colors', it is 'the map is finished, there are no keys'. The lazy build never ran, so a later color() wrote into zero keys and the part rendered uncolored, silently. color("red") linear_extrude(10) square(10) and every other compound operation lost its color.

A color map is accepted only if it describes the faces of the shape it is set on; anything else means the caller has nothing to say, which is exactly what None means. An empty map is rejected without a traversal. This covers the disk-cache restore too, where an older bcad's empty entry did the same thing. One caller was genuinely violating the contract: _transfer_face_colors kept the source shape's keys alongside the target's, now it returns the target's alone. Audited all 74 test files through an instrumented SCLShape: mismatched maps 1 -> 0.

Tests: tests/test_transform_colors.py (new, 19 cases) - transforms keep their color, and the new class covers the operations that build a new shape, the cache-restored empty map, and a map keyed by other faces; the shape cache is off in that class on purpose, since otherwise whether it failed would depend on whether the cache was warm. Verified against the pre-fix code: 6 failures there, 0 here, on cold and warm cache alike. tests/test_call_arguments.py (new), tests/test_primitive_memo.scad (new), tests/test_linear_extrude_color.scad (new, geometry level - a .scad test cannot see color values), and 2 cases in tests/test_display_shape_metadata.py.

tests/test_scale_cone_asymmetric.scad: the double-scale tolerance was 10 on a volume of 1570.8 and the answer is 1581.02. A cone under a general transform is a ruled B-spline image of one, and the second pass approximates that image again; 0.65% is what those two roundings cost, not the transform. Tolerance is now relative. Same value on the previous revision, so this was my test being wrong, not the code.

Grid: 72/74 .scad green and 4/4 python unittest green. The two failures - test_projection_outline.scad:85 and test_step_export.scad:18 - reproduce on 623d69 with these files reverted, so they are not from this change.

Docs not updated: docs/scl.md and AGENTS.md still describe the old for-loop cost. Left out on purpose rather than half-done. check-in: e198eb1b2e user: johnfound tags: object-expression

2026-09-30
14:38
fields of a node are a flat stream of emissions

Emitting a field behaved unlike emitting geometry. A field was written onto the node that happened to be active at the time, and a reader who missed it looked down the subtree looking for the name - so a nested block's data was invisible from the node value: a = translate(){ e: 32; rotate(45){ c: 42; } } gave a.c == undef, and echo(a) printed { } where the fields had been. Two other things came out of the same wrong model. Reading walked the subtree per field access, so cost grew with the scene, and nothing read a field mid-frame either, so echo() could not show one even when it existed.

The root cause is that a block was treated as a scope for names and the parent was expected to aggregate its children somehow. It does not have to: emitting a field is no different from emitting geometry, and both are just entries in the result of the block. So the result of a block is a flat stream of emissions, and the last emission of a name is the value of that name:

group(){ c: undef; group(){ c: 5; } } == group(){ c: undef; c: 5; } -> 5

The nested block hands its fields to the parent's body at the position where that block stood, exactly when its geometry arrives, and the merge is a plain update. Depth is not consulted at all - only execution order - which is the same last-wins two variables in one scope already had. Reading is therefore a dict lookup, node.fields.get(name): the subtree walk and its _FIELD_MISS sentinel are gone.

The transfer cannot happen at attach time, because push_context links a node to its parent before its body runs, when it has no fields yet - hence it lives in pop_context. A draft (attach=False, the node behind a value expression) is not in the tree, so it is marked in_tree = False and forwards nothing; its fields arrive only when it is really emitted, through add_child_context. That keeps the rule that only emission names: group(){ f = group(){ a: 1; }; } still has no field a, while group(){ f = group(){ a: 1; }; f; } has one.

Emission stays the only thing that creates a field, and only from what leaves the block: an assignment b = 32; binds a local variable and nothing else, so group(){ b = 32; } exposes no field b (b: 32; or b = 32; b; does). Unnamed data expressions no longer warn - 42;, sin(x), [1, 2] are computed and never claimed, which is normal rather than a mistake in the program - and emitted named data at the scene root lands in root.fields, symmetric with node values, which already got a root field.

Deviation from OpenSCAD worth naming: a block is not a scope for names, so a nested block standing below an outer label overrides it (group(){ c: 1; group(){ c: 2; } } is 2, and swapping the two statements gives 1). That is the price of the stream, and the same price a second variable in one scope already pays.

Tests: tests/test_field_scope.scad (new, 6 sections) covers emission vs assignment, dot/bracket/node/full-path reads, the flat stream including the undef equivalence above, last sibling wins, propagation up through translate/rotate/color, a draft keeping its fields to itself until emitted, and zero being a value. tests/test_object_expression.scad section 6 is retargeted at the silent root. Full grid of 102 files green, the other 101 byte-identical. Docs: Field access and deviation 5 in unified_callable.md, section 4.2 in design_unified_eval.md, AGENTS.md, and the _emit_value doctrine. check-in: 623d69d7b5 user: johnfound tags: object-expression

2026-09-28
20:52
builtin module value keeps every object the handler produced

_exec_builtin_module returned children[-1] as the value of an expression call, so everything the handler put into the draft before the last child was silently dropped. Only import() ever produces more than one child (import_file() adds one per body of a STEP), which is why the bug looked sporadic: import(f); emitted every body, but a: import(f) or x = import(f) kept only the last one.

The draft is anonymous - no name, no fields - so its value is what the handler produced there: one object -> that node, two or more -> a list (_one_object, the same rule blocks and loop bodies follow), none -> the draft itself, which is what handlers that set the shape directly (surface(), a failed import) need. Emission is still the only grouping point, so the value form and the statement form now produce the same tree, and x[0]/x[1] work like in OpenSCAD.

While here, the same children[-1] trap in the primitive handlers (_handle_polyhedron/cube/cylinder/sphere) is replaced by a walk over all children, so a cache key can no longer land on the wrong child.

Tests: tests/test_import_value.scad (7 sections) with the new fixture tests/import_multi.step - a 10^3 cube plus an r=8 sphere, so the total volume 3144.66 tells the two bodies apart from either one. It covers the value form, is_list/len, indexing, emission through a label and through a variable node, a module field, center=true and a transform; it also pins that a single-body import stays a plain node, so test_import_center.scad keeps working. Verified to fail on the old code at its first assert.

Docs: builtin-module-without-block value shape in design_unified_eval.md 4.1.1 and AGENTS.md (unified callable model). (user: johnfound) check-in: fcc5f18b20 user: johnfound tags: object-expression

14:55
intersection_for value form returns the intersection node, not a list

Previously the value form of intersection_for used the same handler as for and returned a list of iteration values, so a value-position intersection was impossible: x = intersection_for(i=list) { ... }; gave N separate objects (volume() summed them, linear_extrude extruded them) instead of their intersection. The intersection was only ever computed in statement mode.

Now the value form is the statement form: one detached SCLIntersection node built from the collected iteration values (_intersect_value), the same node the statement path creates with attach=True. Data-only or empty body yields a node with an empty shape (volume 0), like intersection(){} - normal, not an error, and silent. Both forms share one cache key (intersection_for + counters + child keys), so the boolean op is computed once per render.

Unification: _for_values is the single value-collection helper for for and intersection_for; _intersection_result is the single aggregation routine (cache key + shape + colors) shared by the builtin intersection(){}, the statement form and the value form. This also drops the dead 'if child_keys is not None' checks (collect_child_cache_keys returns a tuple or raises) and gives both loop nodes a source line for warnings.

Tests: new value-form sections 8-15 in test_intersection_for.scad with real volume assertions; test_for_expression.scad and test_block_value.scad updated to the new semantics. Docs: value shape rules in design_unified_eval.md (4.1.1, 4.4, Step 1). check-in: 6fc6d7bd67 user: johnfound tags: object-expression

13:58
Fix list repr in echo/str and unify for/intersection_for evaluation

Two reported bugs, one root-cause refactor behind them.

scl_str had no branch for lists, so echo(), str() and assert() messages printed the raw Python repr of a list. Added a recursive list branch; the value domain is closed (undef/bool/number/string/list/dict/node), so no Python representation can leak out.

_eval_block_value returned only the last child of a block, so `a: for (i = [0:1]) { cube(1); translate([1,0,0]) cube(1); }` produced one cube per iteration instead of two. A block is an anonymous sequence with no node of its own, so its value is the objects it produced: one object yields the object, two or more yield a list, nothing yields undef (_one_object). A one-element list is never produced, so `x = for (i = r) cube(i)` stays flat and x[0] stays a node.

Grouping nodes are created only at emit time. _emit_value is the single place where a list value becomes a scene node; it flattens nested lists into one group with flat children. _get_shape flattens the same way, so volume(), bbox_*(), num_faces() and is_valid() accept nested lists.

for/intersection_for had three implementations (statement, value, comprehension) and four copies of the counter parsing. _for_counters now parses counters once for all modes, and _exec_for is the only traversal: emit=True is the statement path and the only place SCLGroup and SCLIntersection are created, emit=False is the value path with no grouping node. Side effects of the merge: progress and cancel now work in value mode too, and a string counter iterates per char in statement position as well (previously one iteration).

Also fixed a crash found while testing: a for loop in value position inside profile() raised AttributeError, because contour primitives write to the live context and a draft context has no line/arc/spline. A value-position block inside a contour context now runs against the live context, exactly as the statement path does.

Tests: tests/test_scl_str.scad, tests/test_block_value.scad. Docs: value shape rules in design_unified_eval.md (4.1.1, Step 1 closed), block value is a list in introspection.md, unified_callable.md. check-in: 19b7c4382f user: johnfound tags: object-expression

2026-09-25
14:11
Attempt to optimize the rendering speed for pretty complex models. Not tested to the end. check-in: 4aaf53fdb1 user: johnfound tags: object-expression
11:08
Fix compound color projection and add memory diagnostics check-in: 9d0d7a895a user: johnfound tags: object-expression
2026-09-23
15:13
Unify value-for: for/intersection_for in value position always return a list of values (data and/or isolated geometry nodes); SCLGroup assembly moved to emission (_emit_value builds a group from list geometry, warns on non-geometry, keeps the raw list as label field). Geo functions (volume, num_faces, bbox, ...) accept a list by compounding its geometry. Fixes TypeError: 'SCLGroup' object is not iterable on nested geometry value-for. Add tests/test_for_expression.scad (11 checks). check-in: aa2c31ffcd user: johnfound tags: object-expression
08:43
Add design doc for unified expression evaluation check-in: a9c39a107c user: johnfound tags: object-expression
2026-09-22
21:31
Replace disable/background modifiers * and % with ~ and @ (variant C)

- The modifiers were ambiguous with the TIMES/MOD binary operators: shifting '*'/'%' after a call into a braceless child modifier broke arithmetic (sqrt(4)*3 -> ParserError 'invalid expression 3', sqrt(4)*a -> silent disable printing 2 instead of 6); the same dual role glued 'translate(){...} *g();' into a multiplication at element level. - Variant C: swap the modifier symbols to free tokens: '~' = disable (was '*'), '@' = background (was '%'). '!' (root) and '#' (debug) are unchanged. - Lexer: new tokens TILDE (~) and AT (@); TIMES/MOD stay binary-only. - Parser: p_element_modifier now TILDE/NOT/AT/HASH with a new mod_map; p_child_modifier stays NOT/HASH only. Old syntax '*g();'/'%g();' is a deliberate parse error. - LALR conflicts: 149 -> 145 shift/reduce (dual-role element modifiers gone), 120 -> 124 reduce/reduce (TILDE/AT as element-starting tokens). - Tests: *->~ and %->@ in test_modifiers*.scad and test_math_after_call.scad; libraries threads.scad/rings.scad migrated; scl_examples migrated on disk. - Docs: AGENTS.md, docs/unified_callable.md, docs/scl.md, docs/export_module.md updated (syntax, conflict counts, 3-phase !/~ evaluation). - Full regression: 89/89 tests pass (via bcad.venv). check-in: 1d2f499f5d user: johnfound tags: object-expression

17:05
Fix arithmetic after a call broken by TIMES/MOD child modifiers - Remove TIMES/MOD from child_expr (p_child_modifier): both are binary operators as well as disable/background modifiers, so shifting '*'/'%' after a call into a braceless child modifier broke arithmetic: sqrt(4)*3 raised ParserError 'invalid expression 3', sqrt(4)*a silently printed 2 instead of 6 (disable ate the operand); the same bug hit '%' - Braceless child modifiers are now only '!' (root) and '#', which have no binary-operator meaning and keep translate() !union(){...} working; disable/background on a braceless child are written with braces f() { *g(); } or in statement position (*g(); - element modifier) - LALR conflicts: 153 -> 149 shift/reduce (the 4 conflicts on TIMES/MOD after call), 120 reduce/reduce unchanged - Docs: AGENTS.md + docs/unified_callable.md conflict counts and modifier rules; new regression test tests/test_math_after_call.scad (9 cases: call*var/call*number/call%number/call*call/number*call/var*var, disable in braces and statement position, braceless ! child) (user: johnfound) check-in: 1eb262807f user: johnfound tags: object-expression
16:06
Wave 1 of unified expression grammar: everything is an expression

- One expression category instead of the statement/expression split: sequences are block_list of element (expression SEMICOLON?), statementize() wraps expr_* nodes into stat_* nodes the interpreter already understands - body as the single source of truth for if/for/let/module bodies (expression | ID COLON expression); ';' is NOT absorbed into the body, which fixes the RecursionError where a function body swallowed the next statement - 'if (c) a; else b;' idiom supported at the block_list level via _merge_else; dangling else binds to nearest stat_if, else-if chains work; else without matching if raises ParserError (not SyntaxError - ply swallows the latter into error recovery) - label statements (name: expr;) bind in eval_scope phase 1, emit as a field of the active node in phase 2 - block literal {...} as an expression: statement position emits into the active context (eval_scope), value position evaluates an isolated draft via _eval_block_value - child_expr : call child_block - a call with a block body binds as the braceless child of another call (fixes rotate_extrude() profile(){...}: the block is no longer detached into a separate block literal, which produced 'rotate_extrude: no valid child' plus AttributeError on move/line running in an isolated SCLPart3) - _unwrap_iflet for nested list comprehensions; comprehension-let (body node) vs expression-let (statement block) - LALR conflicts: 96/71 -> 152/120 shift/reduce; now 153 S/R (+1: OBRACE shift after call in child_expr context), 120 R/R - docs: AGENTS.md + docs/unified_callable.md; new regression test tests/test_call_child_block.scad check-in: 82fa332541 user: johnfound tags: object-expression

13:14
Merge trunk into object-expression - sync the branch with trunk before the syntax unification work (label statements + phase-1 binding, braceless child member access, echo/assert transparent nodes, object_return example) check-in: 9585fc60d5 user: johnfound tags: object-expression
13:12
Label statements: bind in phase 1 of eval_scope - labels 'name: expr;' define the variable during phase 1 (like 'v = expr;' assignments, merge last-RHS-wins); phase 2 emits the field from the phase-1 value, with a late-binding fallback for modifier-wrapped labels ('!x: 5;'); forward references stay undef (OpenSCAD semantics) - docs (unified_callable.md, AGENTS.md) and tests (test_unified_callable.scad section 10) updated - full regression 87/87 pass leaf check-in: b3f6f94bce user: johnfound tags: master, trunk
10:36
Fix echo/assert expression nodes and braceless child member access

- _exec_builtin_module transparent branch (echo/assert) now sets _cache_key on the SCLGroup draft like _handle_group, so the node can be used as a child of translate/union etc. without 'has no _cache_key' assert - grammar: new rules child_value : child_value DOT ID | child_value OSQUARE expression ESQUARE (p_child_value_postfix) bind postfix .member/[index] to the braceless child (f() x.y == f() (x.y)); raw call bases are normalized to expr_call - p_child_value branch order fix: len(p)==4 (parenthesized child) checked before isinstance(p[1], str), so f() (expr) no longer becomes expr_id('(') -> 'Unknown variable (' - LALR conflicts 92->96 S/R (71 R/R unchanged); AGENTS.md and docs/unified_callable.md updated (grammar, field access, deviations) - tests: section 8 of test_unified_callable.scad covers the crash repro, whole-node child, .body via label, parenthesized child, call-child member access - full regression: 87/87 pass check-in: e052219b1b user: johnfound tags: master, trunk

07:24
Add object_return example: labeled fields demo

bcad/assets/examples/object_return.scad - parametric design demo for the labeled-fields syntax: modules return a node carrying geometry and data fields; label 'name: expr;' binds a local variable and exports it as a field; random heights/radii (rands) make the caller build the whole composition from returned fields only (full path into nested nodes, echo of result parameters). Picked up automatically by the Examples menu. check-in: 04b496da09 user: johnfound tags: master, trunk

07:01
Label statements: assignment-with-export; auto-name node fields

- label 'name: expr;' (stat_field) now defines a local variable in the current scope AND stores the value as a named field of the active node - one line instead of 'v = expr; v;'. Works in any statement position, including braceless bodies ('if (flag) ok: 7;'); top-level labels emit the node into the scene as well. - 'name;' with a node variable additionally stores the node as field fields['name'] (auto-naming), so nested fields are reachable by full path ('h.pilar.number'); labels with node values behave the same. - grammar: statement : ID COLON expression SEMICOLON, added to child_statement too; LALR conflicts unchanged (92 S/R, 71 R/R) - ID COLON resolves by shift. - docs (unified_callable.md, AGENTS.md) and tests (test_unified_callable.scad) updated; full regression 71/71 OK. check-in: 20fb9dce51 user: johnfound tags: master, trunk

2026-09-21
18:38
Merge object-expression into trunk: unified callable model

Brings the experimental object-expression branch into trunk (fast-forward: trunk did not move since the fork at 30602baa0a; no conflicts).

- Unified callable model: functions and modules are one concept; SCLFrame.callables holds dual-slot bundles {'function','module'}, _pick_callable(bundle, emit) selects by call position (statement -> module, expression -> function); same-name function+module coexist, user callables shadow builtins. - module/function calls with child blocks work in expressions; expression statements emit node values; .shape / dot / bracket access on fields. - Recursive child_value chains in expressions (x = rotate(45) translate(...) cube(5);). - Grammar split statement/expression (child_block / child_statement / child_value, body_action as single source of truth); LALR 92 S/R, 71 R/R documented. - capture() removed; geometry stored via plain assignment (docs/introspection.md replaces docs/capture.md); diagnostics work on node values. - echo()/assert() are transparent modules with children support (_pass_through_children), assert guards before children. - Autocomplete, use-handler, warnings/_defining_path polished; docs updated. - Full regression passes on the branch (87/87). check-in: 9e59d36ea7 user: johnfound tags: master, trunk

18:34
Extend geometry introspection docs

- Rephrase intro: functions operate directly on geometry values (object-expression nodes), not just diagnostics. - Note on bbox_min/bbox_max: Bnd_Box grows every side by Precision::Confusion() (1e-7) by design; center (min+max)/2 is exact, size max-min inflated by 2e-7. - Note on empty geometry: all 9 functions return undef when the node has no shape. - New 'Practical recipes' section: size, bbox center, abutment, fit-to-size scale, center-to-point translate. All recipes verified by execution. check-in: 1f31ce0b49 user: johnfound tags: object-expression

17:34
Fix object-expression audit regressions: same-name callables + echo/assert children

A — module and function with the same name coexist (OpenSCAD): - SCLFrame.callables now stores dual-slot bundles {'function', 'module'}: redefining the same kind is silent last-wins, the other kind survives. - _pick_callable(bundle, emit) selects by call position: statement -> module, expression -> function; a single kind works in any position (unified model). - use handler copies only the slots defined by the library into the root bundle (repointing _defining_scope on the copied node); a local slot of the other kind with the same name is not overwritten. - Autocomplete (worker_engine.py) iterates both slots; a name with both kinds appears in both the module and function lists.

F — echo()/assert() are transparent modules (OpenSCAD): - has_block: True + shared _pass_through_children helper (push_stack -> parse_block -> pop_stack): arguments print/assert first, then children are executed through in a fresh module scope. - assert(false, ...) raises BEFORE its children run (guard semantics). - Works in expressions too: x = echo("t") { cube(10); } returns the cube node.

Cleanup: - Removed dead function-kind _handle_echo/_handle_assert and their _builtin_funcs entries (module kinds overwrite them in _builtin_callables).

Tests: - test_unified_callable.scad: section 7 (same-name module+function in both definition orders), section 8 (echo/assert children pass-through: union wrapper, expression, braceless child). - Docs: AGENTS.md, docs/unified_callable.md updated (dual-slot model, echo/assert pass-through, refreshed scl.py line references).

Full regression: tests/*.scad, tests/builtin_args/*.scad, tests/surface/*.scad all pass (87/87). check-in: 7aa76bb650 user: johnfound tags: object-expression

16:41
Remove capture() module; make expression chains recursive; polish callable model

- capture() removed: _handle_capture, capture_module_definition, _builtin_modules entry, SCLCapture class, FUNC_CAPTURE (progress_helper), docs/capture.md. Geometry is now stored via plain assignment: x = cube(5); or x = group() { ... }; (docs/introspection.md replaces docs/capture.md). - Grammar: child_value is recursive (call child_value), so deep braceless chains in expressions (x = rotate(45) translate(...) cube(5);) work symmetrically with statement chains. LALR conflicts: 92 S/R (+2), 71 R/R (unchanged); resolutions documented above p_child_block. - All 23 tests migrated from capture() to assignment-style node values and verified; tests/test_bool_union_torus.scad added to the repo. - _exec_user_function: self.path switches to _defining_path during the body (warnings point at the defining file), restored in try/finally together with pop_stack. - Scope key unified to _defining_scope everywhere (scl.py, use handler). - edge_kinds: TopologyExplorer.edges() dedups shared edges (12 for a cube, like num_edges) instead of double-counting via TopExp_Explorer. - AGENTS.md + docs/unified_callable.md document the unified callable model, recursive child_value, and the accepted OpenSCAD deviations. - Full regression: tests/*.scad, tests/builtin_args/*.scad, tests/surface/*.scad all pass (87/87). check-in: 638d3a4d36 user: johnfound tags: object-expression

15:04
Unify functions and modules into a single callable concept (object-expression)

Callables - SCLFrame: functions/modules merged into one `callables` map (get/has/set_callable). UnknownFunctionError/UnknownModuleError replaced by UnknownCallableError. SCLContext: find/set_callable. - Unified dispatch SCL._call(node, emit): user callables first, then builtins. A call in any position yields a value; a statement is a call + emit. - _exec_user_function: expression-sequence body, same contract as modules; the child block is bound via $children_block/$children/_has_children_block, so children() works inside functions. - _exec_builtin_function / _exec_builtin_module(entry, node, emit): emit=True runs the handler in the active context (statement); emit=False builds an isolated attach=False node and returns it (expression). - _emit_value: node values attach to the active tree; data becomes a field of the active node (inside a module) or warns (root / unnamed). - _builtin_callables: merged builtin table with kind='function'|'module'. - Redefinition is silent, last definition wins; user callables shadow builtins. - Function bodies execute with parent = defining scope (aligned with modules).

Grammar (scadlike_parser) - statement : call child_block | call child_statement - expression : call child_block | call child_value - New child_block / body_action / child_statement / child_value; removed action_child_block / expr_child_block; added child_value_statements(). - body_action ALWAYS returns a list (a single child_statement is wrapped): one source of truth for if/for/let/module_def/call body_action. - Accepted deviations from OpenSCAD: braceless bare-expression children and array-literal children require braces; `f(x)[0];` in statement position is parsed as indexing.

Parser bug fixes - child_statement : call SEMICOLON no longer stores ';' as the child block. - body_action normalisation (above) fixes the eval_scope() crash on braceless if/for bodies. - Conflicts after the rewrite: 90 S/R, 71 R/R (previously 125 S/R). Remaining R/R are the statement/expression child_block LALR merge and the pre-existing unary/binary +/-; verified behaviourally.

Autocomplete - worker_engine splits root.callables by definition type into user modules vs user functions.

Tests - tests/test_unified_callable.scad: function body as node + statement call, function as value, children() in a function, braceless child in an expression, silent redefinition, user callable shadowing a builtin. - Full regression: tests/*.scad, tests/builtin_args/*.scad, tests/surface/*.scad all pass.

Remaining minor TODO (experimental branch, before merge) - Document the unified callable model and the new grammar rules in AGENTS.md / docs, including the accepted OpenSCAD deviations. - _exec_user_function does not switch self.path to _defining_path, so warnings from a function body defined in another file may report the wrong path. - Final full-suite re-run before merging out of the experimental branch. check-in: 73e4393061 user: johnfound tags: object-expression

12:21
Modules as values: module calls with child blocks in expressions, expression statements, dot/bracket access

- Grammar: expression : call expr_child_block (braced child block only); expr_child_block declared after action_child_block so statement blocks win the R/R conflict; S/R conflict count stays at the original 125. - statement : expression SEMICOLON (stat_expr): node values attach to the active context, data expressions become node fields. - expr_call dispatch: user modules run via _exec_user_module(capture_result=True); builtin modules (primitives and block CSG) build in an isolated attach=False context and return the created subnode; b = difference(){...} works, and u = union() now yields an empty node instead of undef. - SCLContext.fields: data emitted at module level is stored per-node. - expr_dot/array_access: .shape returns geometry, fields via name/index. - Tests: test_object_expression.scad (16 scenarios), test_object_expression_edge.scad (5); full regression of 19 existing tests passes. check-in: aa11cc64fa user: johnfound tags: object-expression

2026-09-19
09:55
Update imgui_bundle to 1.93.0, re-apply editor patches on new base

- imgui_bundle: 407065b7 -> 2004e261 (v1.93.0), ImGuiColorTextEdit submodule bc5bb95 -> f281364 (wide glyphs/CJK, custom line numbers, SetText by line, autocomplete scroll fix #82), imgui c9a0a84f -> bdd1903 - Re-applied all four bcad patches manually on the new base (3-way merge plus two resolved conflicts); none of the bcad features exists natively upstream, so nothing was dropped - Autocomplete scrolling: upstream #82 replaced by bcad's more complete manual scroll (selected item kept visible on popup open, filtering and arrow keys); upstream scroll variable removed to avoid double scroll - Regenerated patch files via gen_patch.sh and verified the apply/reverse cycle; full setup_imgui.sh run passed from a clean tree - pybind: also expose matching_bracket_corners and gutter_background colors - Docs: docs/cpp-patches.md (update history), cpp/imgui-patches/README.md check-in: 30602baa0a user: johnfound tags: master, trunk

08:44
Restrict Alt shortcuts to left Alt only (right Alt is used for layout switching) check-in: 785c7daec9 user: johnfound tags: master, trunk
2026-09-16
06:16
Added some libraries to the standart library pack. check-in: 3556c99c19 user: johnfound tags: master, trunk
05:32
Add include/use library search path with bundled standard libraries

The angle-bracket form of use/include now resolves against an ordered search path: current file dir -> settings.conf [general] library_path -> bundled bcad/assets/libraries/. Bundled libraries are referenced by bare name regardless of install location.

New library_paths.py is the single resolver; wired into _expand_scope, use/include handlers, and the editor Ctrl+E jump. import()/surface() stay file-relative but no longer crash on self.path=None (unsaved buffer). check-in: e4308b7de5 user: johnfound tags: master, trunk

04:59
Fix intermittent loss of double-click centering

rq_double_click and rq_select were sent with retransmit=False, so the request was silently dropped whenever the GUI was still waiting for a worker reply (a common 1-frame window caused by per-frame hover moves). The discrete double-click event was lost forever - centering failed intermittently. Mark both as retransmit=True so they wait for the channel to be free instead of being discarded.

Also warn to the console when a double-click picks no object under the cursor, so the user learns the click did not match a shape. check-in: 1339e7c9ff user: johnfound tags: master, trunk

2026-09-15
07:31
Reverse the direction of twist in linear_extrude() in accordance with the OpenSCAD syntax. check-in: a523fb21ee user: johnfound tags: master, trunk
2026-08-26
08:34
polygon(): spline modifier, parametric curve trim, docs

- Spline sub-lists accept trailing modifier: [[x,y], r] for fillet/chamfer - Chamfer2._trim_edge(): parametric trim via BRep_Tool.Curve for any edge type - GCPnts_AbscissaPoint: 4-arg constructor (3-arg returned arc length as parameter for non-linear curves, only worked by accident for lines) - docs/polygon_extended.md: new syntax, OpenSCAD compatibility table check-in: 854218e519 user: johnfound tags: master, trunk

2026-08-25
14:10
polygon(): extended point formats - chamfer, fillet, spline sub-lists check-in: 658e603502 user: johnfound tags: master, trunk
2026-08-23
22:01
Sync Linux env scripts with Windows: build FreeType/HarfBuzz/RapidJSON from source

- Add setup_freetype.sh, setup_harfbuzz.sh, setup_rapidjson.sh mirroring the Windows .bat scripts; libraries float on default branches (no version pins). - FreeType/HarfBuzz install into bcad.venv/lib/{freetype,harfbuzz} so consumers reach them via relocatable $ORIGIN-relative RPATHs (same pattern as OCCT); libharfbuzz carries its own RUNPATH to our FreeType - transitive deps do not see consumer RUNPATH and would otherwise silently pick up the system library. - setup_harfbuzz.sh points CMAKE_PREFIX_PATH/PKG_CONFIG_PATH at our FreeType. - setup_occt.sh: USE_RAPIDJSON=ON + INSTALL_RAPIDJSON=ON against cpp/rapidjson headers (pythonocc SWIG glTF module includes them unconditionally). - setup_text_shaper.sh: explicit HARFBUZZ_*/FREETYPE_* cmake vars override the pkg-config fallback; INSTALL_RPATH extended with freetype/harfbuzz lib dirs. - setup_imgui.sh: add IMGUI_BUNDLE_BUILD_DEMOS=OFF and IMGUI_BUNDLE_INSTALL_CPP=OFF; apply patches via git apply instead of patch(1) (stricter, matches .bat). - Unpin Windows FreeType VER-2-13-3 / HarfBuzz 10.2.0, track floating default branches instead; FT_DISABLE_ZLIB/PNG/BZIP2=ON on both platforms; fix no-op update in setup_rapidjson.bat (reset --hard HEAD never moved the clone). - pip version caps: nanobind below 3.x and swig below 4.5 on both platforms. imgui_bundle bindings break against the nanobind 3.x ndarray_export API; pythonocc-core 7.9.3 only accepts SWIG 4.2.1..4.4.1. Drop after upstream fixes. - docs/build.md: ft/hb/rapidjson are built from source, no system packages. check-in: f586182777 user: johnfound tags: master, trunk

12:00
Merge imgui_bundle upstream (407065b7, TextEdit bc5bb95) into patches

Upstream adopted: SetText scrolling reset via resetScrolling() (#78), hover crash fix on empty lines, ImGui::Shortcut-based navigation, caret rendering rework, squiggles API. No duplication of our features found; both scroll fixes coexist (upstream resets on SetText, ours keeps cursor visibility requests alive during typesetter updates).

Adaptations of our patches: - Rejected hunks re-integrated into new contexts: previousCursorLine reset in setText (now after clearSquiggles), updateState guard additionally checks ensureVisiblePos.line, scrollToLine no longer clears a pending ensureVisiblePos request (verified against new handlePossibleScrolling ordering), deleteText sync follows new deletesHappened bookkeeping - Ctrl+Q calltip fallback switched to ImGui::Shortcut idiom (isShortcut local removed upstream) - Esc dismissal: calltipState.active added to the outer guard introduced upstream (inner branch was unreachable otherwise) - renderGutterBackground declaration moved after renamed renderCursorCarets - Removed stale whitespace artifact at the updateState section header

All three patches now apply cleanly with patch -p1 (no offsets/fuzz/rejects). check-in: fd4da86764 user: johnfound tags: master, trunk

2026-08-13
14:59
projection(cut=false): keep hole winding in full-circle merge; sphere silhouette respects UV bounds; insert exact projected vertices into polylines

- simplify_loop merged two CW semicircle arcs of a circular hole into a full circle with the canonical CCW winding: _arc_pieces normalized directed fit angles through _angular_pieces and lost the sign of traversal, and _union_angular_pieces collapsed full coverage to the positive sweep. The hole became a filled disk (2 faces, area 160.26 instead of 120.99) for cone_vert. Circle and ellipse pieces are now directed intervals (sign of span is the traversal direction); _pieces_connected and _merge_pieces handle them with the 2*pi seam, and _dir_union_pieces unwraps the next arc to continue the chain, preserving CW for holes and CCW for outer boundaries. - Sphere silhouette: only emit the equator arc lying within the face UV bounds; a partial sphere (rounded corner octant) no longer emits a phantom full circle that extended the merged coverage and created false junction vertices. - insert_projected_vertices: split polylines at the exact projected positions of vertices that are true edge endpoints lying on the same support curve; the merge resampling previously lost the exact line/circle junction points of offset() rounded corners and the contour did not close within the assemble_loops tolerance. - Tests: test_projection_outline gains p5d (offset(1.2) cube, area 152.52) and p5 (cone_vert, one face with circular hole, area 120.99) now passes. check-in: 04915204be user: johnfound tags: master, trunk

05:11
classify_arc: do not replace circle with ellipse on marginal fit gain; add edge_kinds() builtin

projection(cut=false) offset(1) cube(10) exported corner arcs as ellipses instead of exact circles: the Fitzgibbon ellipse fit overfits the noisy line/circle junction endpoints and wins over the Kasa circle fit on a marginal residual edge (1.53e-4 vs 1.90e-4, ratio 0.79). A true flat ellipse improves the circle residual by 6-8x (ratio 0.12-0.17). Now the circle gives way to an ellipse only when the ellipse residual is at most half of the circle residual. Adds edge_kinds(shape) builtin returning the sorted curve types of all edges (line/circle/ellipse/...) for tests. Regression test 5c asserts the four offset-cube corners are circles (area 140+pi=143.14); arg-validation coverage in tests/builtin_args/inspection.scad. check-in: 97dee3486e user: johnfound tags: master, trunk

04:43
Fix projection(cut=false) silently dropping outline edges; snap junction vertices before MakeWire

Flat ellipse outline arcs (cut_false.scad h=15.1/14.9) meet line arcs with ~1e-4 vertex gaps, far above MakeWire's 1e-7 coincidence threshold, so BRepBuilderAPI_MakeWire silently discarded edges and the resulting face was garbage (-15.5 area instead of ~118.7) with no warning. Before assembly, snap each adjacent pair of part vertices to their midpoint via BRep_Builder.UpdateVertex (tolerance = gap + 1e-6). Conic edges stay exact, polyline fallback unchanged. Adds regression test 5b (cut_false h=15.1, area 118.67). check-in: cf95a2f4f3 user: johnfound tags: master, trunk

2026-08-12
16:17
Updated build script. check-in: 4979ba5502 user: johnfound tags: master, trunk
04:55
projection(cut=false): generic silhouette — Adaptor3d_Surface_D1 out-args, half-cell grid, contour tracing check-in: 70d92f525e user: johnfound tags: master, trunk