-
2026-07-10 — Review-pass fixes + new B-spline/falloff math (hoisted from mayatk).
ExposureEqualizer.equalize_directories's new stale-output purge can no longer destroy a source capture: when the output dir resolves onto the source dir itself (output_root=parent+suffix=""), the purge is skipped with a warning and the run overwrites in place — pre-fix, the rmtree deleted the capture before it was ever read (regression test:test_equalize_never_purges_the_source_dir). The two EXIF-loss failure paths no longer double-count one frame (an EXIF-read failure followed by a PIL-save failure on the same frame counted twice) and each gets its own first-occurrence log gate (whichever fired first used to suppress the other's message for the whole run); the run summary attributes the loss to its per-cause errors instead of blaming a missing PIL. Per-run_sample_statsmemo —_reference_statsand the per-capture loop sampled the same directories, decoding every source dir's sample set twice. Stale docs corrected (dhashthreshold default,curateoutput-stem qualification,rasterize_silhouettepersistence note). NEW publicMathUtils.resolve_falloff_profile/bspline_clamped_knots/bspline_basis— the DCC-agnostic falloff-resolver and clamped-B-spline basis math hoisted out of mayatk's skinning module per the generic-helpers-upstream rule, so the blendertk mirror shares one implementation (partition-of-unity / end-pinning / knot-structure tests intest_math.py). Full suite green (1711). -
2026-07-10 —
AppLauncher.scan_install_dirs: glob pattern order is now a real priority (first pattern's matches outrank later patterns'). The old implementation pooled every pattern's hits into ONE reverse-sorted list, so for multi-pattern callers the filename became an accidental tiebreaker inside a single install dir: the RizomUV bridges pass(Rizomuv_VS.exe, rizomuv_RS.exe, rizomuv.exe)as an explicit preference, but the pooled sort yieldedrizomuv_RS.exefirst (lowercase sorts above uppercase) and ranked the barerizomuv.exe— a launcher that ignores-cfiand hangs a headless run until timeout — above the intended VS binary. Found via mayatk's new RizomUV headless probe (see mayatk CHANGELOG same date); latent on machines wherefind_appmisses (resolution only reaches the scan stage then). Matches now yield per-pattern in caller order, newest install dir first within each pattern, duplicates skipped — mirroringresolve_app_path's own first-hit-wins staging. Single-pattern callers (Maya / Blender / Toolbag bridges) are unaffected. Regression-locked (test_bridge.py::test_scan_glob_pattern_order_is_priority+test_scan_glob_priority_still_prefers_newest_within_pattern); full suite green (1723). -
2026-07-10 — Prep primitives, second-pass review fixes.
ImageCurator._clusterwithhash_threshold <= 0now disables clustering outright: Hamming-0 matching still merged bit-identical dHashes (routine for consecutive frames of a paused camera on a thumbnail-sized hash) and kept one per cluster, silently contradicting the "0 = no dedup, keep all" contract every consumer documents (regression test: three byte-identical frames all survive at 0; the representatives-percentile test repinned tohash_threshold=1, which preserves its intent under the new contract). Scan resize height clamped ≥1 (an extreme panorama strip — width > 1024×height — rounded to height 0 and cv2.resize aborted the whole scan).ExposureEqualizer._save_imagecounts EXIF-read failures inlast_fallback_countand logs the first (frames were silently written EXIF-less and uncounted); the per-capture sigma guard is hoisted out of the per-image loop. -
2026-07-10 —
PresetStorewrites are now atomic (FileUtils.atomic_write_text): preset saves and the.activesidecar can no longer be torn by a concurrent reader or corrupted by a crash mid-write.save()and theactivesetter used plainwrite_text/json.dump— another process reading at the wrong moment (e.g. a DCC session applying its startup preset while a second session saves; newly relevant since tentacle now applies the active macro preset at every launch, see tentacle CHANGELOG same date) could parse a partial file, and a crash mid-save permanently truncated the user's preset. Both now route through a module-level_atomic_write_texthelper delegating to the existingFileUtils.atomic_write_text(temp file in the same dir + fsync +os.replace; imported lazily —file_utilsimports fromcore_utils, so a module-level import would risk a cycle). Failure modes preserved:atomic_write_textre-raises after temp cleanup and its failures areOSErrorsubclasses, sosave()still propagates and theactivesetter's swallow-and-log contract is unchanged; a torn read was already handled gracefully by consumers (ValueError→ fall back / skip), so this closes the write side. No API change. Pinned by a newPresetStoreAtomicWriteTestpair (failed save leaves the existing preset readable + no temp litter; failed.activewrite keeps the prior pointer) — both verified to FAIL against the old plain-write_textpath, so a silent revert can't pass. Full suite green (1720 tests); uitk'stest_preset_manager.py(76) green against the changed store. -
2026-07-10 — Photogrammetry prep primitives: quality-regression fixes (ImageCurator, ExposureEqualizer,
ImgUtils.unique_dir_stems). Audit of the extapps photogrammetry pipeline traced several "results got worse" mechanisms into these classes. ImageCurator: the scan thumbnail was decimated 4K→256px with defaultINTER_LINEAR— the aliasing fed pseudo-random high-frequency energy into the Laplacian-variance sharpness ranking (and at 256px real 4K motion blur is sub-pixel, so blur culling was near-random); scans now useINTER_AREAat a 1024px analysis width (SCAN_WIDTH; absolutesharpness_floorvalues recalibrate — the percentile/median-relative floors are unaffected).curate()signature defaulthash_threshold5→0(dedup opt-in; extapps' profile drives production values). ExposureEqualizer: now computes one transform per capture directory by default instead of per-image (per-image normalization applied an unboundedref_std/σgain to dark/low-contrast frames — noise amplification exactly where SfM matching is weakest; legacy mode viaper_image=True); per-source output dirs are purged before writing (overwrite_output=True, mirroring the curator — without it a re-run after tighter curation left previously-equalized, since-culled frames in the set downstream ingests);np.rintbefore the uint8 cast (bareastypetruncated ~0.5 LSB down per channel per pass); the PIL-save fallback tocv2.imwrite(which drops EXIF + the deliberately-unbaked Orientation tag) is now counted (last_fallback_count) and logged loudly instead of silent; TIFF writes warn once that EXIF/16-bit don't survive. Both classes key per-source output dirs by the newImgUtils.unique_dir_stems(basename, parent-qualified on collision) — two captures likecapA/images+capB/imagespreviously mapped to one output stem, and the curator's purge deleted the first capture's just-copied files. Consumers: extapps photogrammetry workflows (see extapps CHANGELOG same date). -
2026-07-10 —
NetUtils.is_port_bindable: the bind-vs-connect port-probe split, as a shared primitive. A hung (zombie) process can hold a TCP port bound but not listening: a connect probe (is_port_open) reads that as free, yet a new server'sbind()still fails — anything launched on that port then waits out its whole startup timeout (this exact failure burned three consecutive mayatk test-runner launches on 2026-07-09). The new helper answers the launch-side question ("can a NEW listener bind this?") by actually binding;is_port_openremains the attach-side question ("is a service listening here?") — the docstrings now spell out which to use when. Consumers:mayatk.MayaConnection.get_available_port(refactored off its inline socket code) and extapps'PainterConnection.get_available_port(which had the same latent flaw). Tests: free-port + zombie-bind regression pair intest_net_utils.py(the zombie case also pins the semantic difference againstis_port_open). -
2026-07-09 — Atlas primitives:
ImgUtils.atlas_pixel_rects+ImgUtils.inset_atlas_rects;assemble_atlasrefactored onto the shared pixel mapping (mayatk lightmap atlas-gutter support).compute_atlas_layouttiles the unit square exactly, which leaves zero spacing between neighboring rects — any mip level or bilinear tap near a rect edge bleeds into the next item, and a consumer that wanted to dilate the assembled atlas had to re-deriveassemble_atlas's private UV→pixel rounding (and its vertical flip) to build a coverage mask, inviting off-by-one drift. Two new pure primitives close both gaps:atlas_pixel_rects(rects, size)is now the single source of truth for the normalized-rect → integer-pixel-rect mapping (flip + rounding);assemble_atlasitself places content through it, so a mask built from it is exact by construction.inset_atlas_rects(rects, size, gutter)shrinks each rect by a pixel gutter per side (per-axis inset capped at a quarter of the rect's extent, so tiny rects never lose more than half and degenerate rects pass through unchanged) — the returned rects remain validscaleOffsetvalues for sampling the inset content, and the freed border is the consumer's dilation margin. First consumer: mayatk'sLightmapBaker.pack_atlas(inset + exact-maskdilate_imagegutter fill; see mayatk CHANGELOG same date). Tests:test_img.py+7 (AtlasPixelRectsTest: full-canvas/flip/size-tuple/shared-edge-no-gap;InsetAtlasRectsTest: gutter-freed-all-sides/tiny-rect-protection/inset-stays-inside), full img suite green.