Skip to content

blendertk v0.5.31

Choose a tag to compare

@m3trik m3trik released this 29 Jul 04:24
  • 2026-07-28 — The Qt-only suites now actually run: run_tests.py re-runs a skipped suite under the workspace .venv, which immediately surfaced a broken test_blender_native_menus. Headless Blender ships no Qt binding, so seven suites (test_blender_ui_handler, test_blender_native_menus, test_rizom_construction, test_hierarchy_tree_utils, the shot panel/slots harnesses) self-reported ===RESULT: PASS=== (skipped) and the runner printed SKIP and moved on. They were documented as .venv targets, but nothing executed them automatically — 287 checks that only ever ran when someone remembered to run them by hand. run_suite now takes an optional python=, and run() retries any skipped suite under .venv (labelled [venv] in the output), accumulating totals after the retry so the venv result is the one counted; a suite that skips in both environments still reports SKIP. Blender remains the default — the session-safety rule governs the bpy suites, and these carry no bpy import. The gap was hiding a real failure: test_blender_native_menus patched blendertk.ui_utils.menu_harvest.refill_qmenu, a module-level target that stopped existing when the module migrated to the MenuHarvest class (production correctly calls menu_harvest.MenuHarvest.refill_qmenu). mock.patch resolves its target string at call time, so the stale name passed import and collection and only raised AttributeError when the suite was finally executed — the same moved-symbol class the ecosystem has been bitten by before. Repointed to mock.patch.object(menu_harvest.MenuHarvest, "refill_qmenu"), which binds the attribute at patch time and so cannot silently rot again.

  • 2026-07-28 — Smart Bake spacer parity (see mayatk's CHANGELOG for the detail). smart_bake.ui's trailing verticalSpacer flipped from the implicit Expanding to Fixed at height 0, matching the Maya SSoT, so the collapsed output group snaps flush to the footer instead of leaving a dead band. The underlying adjust_height_by double-count lives in uitk and is fixed there for both DCCs. Measured through the real BlenderUiHandler load (Qt-only, no bpy): collapsed gap 2 px in every state, window snapping to 254 with maximumHeight locked when collapsed and free when expanded — identical to the Maya panel. test/test_smart_bake.py 14/14.

  • 2026-07-28 — Shell Xform move-pad scope + snap (mayatk parity — see its CHANGELOG for the detail). gridMove and the <customwidgets> block are lifted verbatim from the Maya SSoT, so the two move pads cannot drift by hand-editing; parity_map needed no new entry (sweep clean). New UvUtils.get_uv_bounds(objects) -> (u_min, v_min, u_max, v_max) mirrors mayatk's, read off the active UV layer via _uv_read as a running min/max rather than collecting every loop UV — the move pad calls it per arrow press. The arrow slots now route through _move, which resolves the scope, reads the cached snap toggle, and calls ptk.MathUtils.step_offset — identical logic to the Maya twin. _snap_enabled deliberately does not import the Qt option-box internals: headless Blender has no Qt binding, and shell_xform_slot_check.py drives _move there (+9 checks: three scopes, opposite-arrow cancellation on both axes, Selection Bounds, snap on/off). test_uv_utils.py +3 (bounds order, bounds track a move, empty selection → None), and the slot check asserts the _MOVE_SCOPES shape now that the combo is built from it rather than from the .ui.

  • 2026-07-28 — Render Opacity's Fade Direction tooltip lost half a bullet to an unescaped <. "… ≥ 0.5 → fade out; if < 0.5 or no key → fade in." is handed to Qt, which auto-detects the surrounding fmt() output as rich text — so the bare < opened a tag and everything up to the next > (the bullet's own </li>) was swallowed: the Auto entry rendered as a truncated fragment. Now &lt;, with a comment saying why. Caught by a static pass that evaluates every fmt() call in the ecosystem and parses the result; all 224 literal tooltips across mayatk / blendertk / tentacle / extapps now come out well-formed (the pass also has to whitelist named entities like &nbsp; / &mdash;, which Qt renders but XML doesn't predefine).

  • 2026-07-28 — All 42 panels reach the tooltip DSL through self.sb.tooltip instead of importing uitk.widgets.mixins.tooltip_mixin by path (mirrors mayatk's sweep — see its CHANGELOG for the method and the verification). 41 modules here dropped the import, including the guarded try/except ImportError form in arnold_bridge and lightmap_baker and their if TooltipFormat is not None: branches. Mechanical + AST-verified; no behavior change, registries unaffected.

  • 2026-07-28 — Panel tooltip pass (mirrors mayatk's — see its CHANGELOG for the detail). Emissive Groups' 13 tooltips rebuilt through the new self.sb.tooltip Switchboard passthrough (uitk CHANGELOG), which also retires this module's try/except ImportError TooltipFormat guard and the if TooltipFormat is not None: branch around the header help — the panel is a uitk slots class, so the guard could never fire. Same content fixes: the Encoding combo states the 4-group cap as a combo-level note (it comes from the manifest's RGBA slot channels, so it binds both encodings — not a vertex-color property), Remove Group bolds the retired-slot contract, Make Weights Keyable splits the two DCCs' curve transports into a section, Remove Keyable Weights drops the shouty INCLUDING. .ui text: Mirror's chk007 Instance tooltip gains the disabled Bounding-Box-(center)-pivot restriction and names the bake path; Cut On Axis's four weighted-spacing fields (Spacing / Distribution / Bias / Curve) say what their values actually do, written from a measured ProgressionCurves run — which caught a first draft that had Bias backwards and mis-described Ease In-Out and Smooth Step. The smooth_step-ignores-Curve discrepancy in toggle_weight_ui is left standing and documented in mayatk's entry. Text and structure only — the files stay character-identical to their mayatk twins where the parity contract requires it (sweep: 0 deltas).

  • 2026-07-28 — UvUtils.auto_unwrap + a public transfer_uvs (mayatk parity). auto_unwrap(objects, method="hard"|"organic", …) mirrors mayatk's: each mesh exports to OBJ, is unwrapped by ptk.UvUnwrap (Ministry of Flat for hard surface, Boundary First Flattening for organic) and gets its UVs transferred back, inside one undo step with the prior mode/active/selection restored and every imported object plus orphaned mesh datablock removed. Per-object failures roll that mesh back via get_uv_coords/set_uv_coords and are reported rather than raised; the engine executable is resolved before the scene is touched, so a missing engine can't leave a half-run. Both engines preserve topology exactly, so the transfer is an exact per-loop copy and nothing is triangulated. Layout is per engine — Ministry of Flat's own island arrangement is kept and only scaled into the tile by the new data-level _fit_uvs_to_tile (no operator, so it runs headless), while BFF, which only flattens, gets the full _pack_shells pass. transfer_uvs is now public, closing a standing parity gap: the exact-copy / data_transfer spatial-fallback pair logic the RizomUV bridge carried privately is now one implementation on UvUtils, and the bridge delegates to it (keeping only its logging). Verified against both real engines in Blender 5.1 (topology preserved, UVs inside 0-1, no leftover objects); test_uv_utils.py green, rizom round-trip green.

  • 2026-07-28 (parity batch) — Thirteen Maya-parity gaps closed in one sweep: every ledgered pending item built (or decisively closed), plus three rolled subsystems Blender doesn't ship. New engines: SelectionOrder (ordered OBJECT selection — a depsgraph-diff click tracker + reorder_objects sort methods; Blender natively records component order only) backing the un-hidden Reorder Selection menu; CameraVisibility (per-camera exclusive/hidden isolate sets as camera id-props, derived from scene.camera with an exactly-reversible stash + optional msgbus auto-re-apply — Maya's SetExclusiveToCamera family) backing six new cameras-menu leaves; and the NurbsUtils CV-deformation family (bend_curve arc-length-exact circular bend, curl_curve, scale_curvature, straighten_curve, rebuild_curve uniform arc-length CV redistribution, extend_curve end-tangent extension — pure control-point math; the probed SIMPLE_DEFORM route stays refuted) backing six formerly-dead nurbs list leaves. Engine upgrades: EditUtils gained propagate_normals/conform_normals/extract_reversed_faces (Maya's five polyNormal modes now 1:1 in the normals menu), combine_objects/separate_objects gained uninstance=True (both linked-duplicate corruption hazards VERIFIED real headless — join mutates the sibling sharing the active's datablock; separate deletes sibling faces), and cut_along_axis gained spacing/distribution/weight_bias/weight_curve via the shared ptk.ProgressionCurves (linear default reproduces the old even-fill exactly). UvUtils gained scale_uvs (whole-map scale about a pivot) + transfer_uvs_to_similar (bbox-volume+vertex-count fan-out with TOPOLOGY data-transfer — the analogue of mtk's component-space transfer; linked duplicates of the source skipped). XformUtils.transfer_pivot gained mirror= (reflect the source origin across a world axis-plane before the ORIGIN_CURSOR snap). TelescopeRig gained aim_axis (signed x/y/z → Damped-Track enums + driven scale channel + off-axis locks, mirroring mtk's _resolve_axis) with the Aim Axis combo added to its .ui. Decisively closed, not built: Bake Inherited Visibility — Collection.hide_viewport/hide_render are NOT animatable (is_animatable=False; keyframe_insert and driver_add both refuse, verified live 5.1), so no animated inherited-visibility source exists; a full collection-chain bake pass was prototyped and reverted on this finding, and the scope cut is now documented with that fact. All engine work verified in fresh headless Blender 5.1 (test_edit_utils / test_mirror_cut / test_uv_utils / test_xform_utils / test_telescope_rig / test_selection / test_cam_utils / test_nurbs_utils — new coverage in each; test_smart_bake 117/117 post-revert). Post-batch critique fixes (same day): the SelectionOrder handler is @persistent (non-persistent handlers are cleared on file load — a startup-armed tracker would have silently died on the first File▸Open) with a render-context guard; rebuild_curve gathers by index and removes/recreates with fresh lookups (holding spline references across splines.remove() risks dangling RNA on multi-spline curves — regression-tested incl. an untouched Bézier sibling); transfer_uvs_to_similar's restore path uses the view-layer deselect loop (object.select_all poll-fails from the Qt-pump context). Live-GUI passes owed, per the standing pattern.

  • 2026-07-28 — Scene Exporter: the FBX now carries ONE scene-range animation take, and the tasks that shape it are no longer inert. Three linked defects, all found while verifying the Emissive Groups keyed-weight transport. (1) _DEFAULT_FBX_OPTIONS left bake_anim_use_nla_strips / bake_anim_use_all_actions at Blender's True defaults, under which export_fbx_bin.fbx_animations writes one take per action — each baked over that action's own range and start-zeroed — and, per the if not … and not … guard right there in the source, skips the scene-range take entirely. So independently-authored curves (a mesh's action; the staged emissive weight-curve proxies) landed in separate takes each rebased to frame 1, i.e. silently time-misaligned in the engine, and nothing in the file honoured the scene range. Both are now pinned False (also in presets/default.json), matching what mayatk's FBX path produces; per-shot takes remain the opt-in job of the apply_declared_takes task. (2) set_linear_unit and set_bake_animation_range used the set_/revert_ pair, which TaskFactory fires when run_tasks returns — before the write — while the FBX writer reads scene.unit_settings.scale_length and the scene frame range at the write. Both tasks were therefore inert. They now stage their restore via TaskFactory.stage_deferred_restore (first-stager-wins per key, so a later widen builds on the true original) and return None to keep the too-early pairing disarmed; revert_linear_unit / revert_bake_animation_range are gone. (3) export_data_node stages the weight-curve proxies after set_bake_animation_range has run — and that task is a user checkbox that may be off — so it now widens the range itself (_cover_frame_range, widen-only) to cover them; a weight keyed past frame_end used to ship flattened to its extrapolated value. The restores are drained from a finally spanning the whole task+write span, so an aborted task, a failed check, or an empty export set can no longer leave staged state or proxies in the user's scene (that path also leaked the open .log handle). Scene reads in the task manager go through a new _scene() that routes via CoreUtils._active_view_layer, so they inherit the package's one fallback chain for the window-less Qt event-pump context rather than re-deriving it.

  • 2026-07-28 — New test/run_tests.py: the suite now reports 2499 individual checks instead of "85 suites", and stamps a README badge like every sibling package. Run-Tests.ps1 counted suites — it printed ALL 85 SUITES PASS and threw away the per-check (N/M) each suite already prints, so blendertk had no comparable coverage number and (alone among the ecosystem packages) no Tests badge at all. The new Python runner keeps the hard session-safety rule unchanged (one fresh --background --factory-startup Blender per suite, never an attach) and adds: per-check tallying straight from the universal OK … / FAIL … line convention (so all 85 suites count, not just the 54 that happen to print an (N/M) suffix), a suite that dies before reporting charged one failure rather than vanishing from the totals, Blender discovery via ptk.AppLauncher.resolve_app_path (--blender > BLENDERTK_BLENDER > newest install > PATH) instead of a hardcoded 5.1 path, --list, named-suite runs, and a badge stamped through ptk.StatusBadge — full runs only, so a scoped run can't publish a partial count that reads as a coverage regression. Run-Tests.ps1 is now a thin wrapper so the documented PowerShell invocation still works. Counting prefers a suite's own (ok/attempted) tally over line-counting where present — the suite knows things the runner can't, e.g. that a multi-line traceback appended to lines is one failed check. Four suites were silently contributing zero: test_shot_manifest_panel, test_shot_sequencer_panel and test_shots_slots run through unittest and printed a bare verdict (they now emit (ok/attempted), with attempted excluding skips so a fully skipped suite reports (skipped) and contributes nothing rather than counting its skips as failures), and test_rizom_construction printed a bare PASS on its no-Qt skip path — now tagged (skipped) headless and carrying its real (24/24) under the workspace venv, where it actually runs. A suite that passes while reporting no checks is now named in a warning instead of blending into the green. Verified on a full run: 85 suites, 2499 checks, 0 failures (227s), 6 correctly reported as skipped. Standard: m3trik/docs/TEST_BADGE_STANDARD.md.

  • 2026-07-28 (curve transport) — Emissive Groups: Blender-keyed weight curves now reach Unity — the Scene Exporter ships them as transient curve proxies, closing the keyable-weights export divergence. Blender's FBX exporter has no custom-property animation path (re-verified exhaustively in the 5.1 io_scene_fbx source: the animation writer bakes only transform / shape-key / camera channels, and the new native C++ FBX is import-only) — so the curves ride a channel it DOES bake. New EmissiveGroups.create_export_curve_proxies(): one transient Empty per keyable group with a weight fcurve, named exactly the group's manifest attr, parented under data_export, its scale.x keyed with the weight curve — scale because it is the one transform channel unit conversion never touches (translation is unit-scaled, rotation degree-converted). export_data_node stages the proxies into the export set and registers remove_export_curve_proxies on the task manager's new transient-export-cleanup seam (run_transient_export_cleanups) — needed because task reverts run before the FBX write (the documented export_data_node ordering), while the proxies must exist THROUGH the write and vanish after; perform_export runs the cleanups in its finally AND on the checks-failed early return (a later check can fail the run after staging). Scene hygiene holds: proxies are marker-propped (PROXY_MARKER), strictly export-transient, pre-cleaned on every staging, and validate() flags leftovers from an interrupted export; a squatted proxy name (a user object already named emissiveGroup_<x>) skips that group with a warning rather than clobbering. Unity-side the importer matches proxy node name == manifest attr, rebinds m_LocalScale.x to the controller, strips the remaining baked transform curves, and deletes the node from the prefab — both DCCs' keyed weights arrive identically bound. Verified end-to-end headless (fresh Blender 5.1): engine suite +9 checks (proxy shape/keys, FBX round-trip via FbxUtils with the imported scale.x evaluating 1.0→0.0, squatted-name guard, leftover-validate) and test_scene_exporter.py +4 (full perform_export pipeline: FBX carries the animated proxy + the manifest attr join key, scene left proxy-free). Slots help/tooltips updated (still spliced identical, parity 0 deltas).

  • 2026-07-28 (keyable weights) — Emissive Groups: group weights are now optionally keyable in-Blender (mirror of mayatk's make_weights_keyable / key_weight / remove_keyable_weights), with the FBX transport divergence documented. Same API and registry/manifest contract as the Maya side: one keyable 0-1 custom property per group on the data_export Empty (emissiveGroup_<name>, seeded from the group default, id_properties_ui limits), keyed via fcurves (key_weight resolves the current frame window-independently through the active view layer's owning scene — no bpy.context.scene, honoring the module's screen-context AST guard); remove_keyable_weights strips props + fcurves via the slot-aware AnimUtils helpers; remove_group cleans its prop; set_default follows through to un-keyed props only; the manifest records each attr. Divergence (documented, not a reduction): Blender's FBX exporter bakes only transform / shape-key / camera channels (verified in the 5.1 io_scene_fbx source), so the curves stay in-DCC — the static prop and the manifest attr record still export. Same standing limitation as RenderOpacity's visibility-channel mapping. Panel: the same Keyable table-menu section (slots spliced identical, parity 0 deltas). validate() also gains an orphan-carrier-prop warning — an FBX reimport restores the keyable props but not the registry (data_internal never rides the FBX), the prop-side twin of the existing orphan-membership warning. Also fixed in passing: a duplicated import bpy in add_group. Headless suite +14 checks → PASS (fresh Blender 5.1). Release note: publish pythontk first.

  • 2026-07-28 — New Emissive Groups tool (mat_utils/emissive_groups.py + .ui): full mirror of mayatk's — same public names, registry schema, manifest wire format, and panel surface (parity sweep: 0 deltas). Membership storage diverges by design and is documented in STRUCTURE.md: Maya's face objectSets become per-group boolean FACE attributes (emissiveGroup_<name>) — per-group booleans, not one int ID attribute, so overlapping membership stays expressible exactly like Maya sets; faces arguments take {object_name: [face_indices]} ([] = whole mesh) or the selection. bake_vertex_colors() writes the emissiveGroups color attribute as BYTE_COLOR/CORNER (per-corner = hard group boundaries, the Maya per-face-vertex analogue); bake_mask() harvests UVs via calc_loop_triangles into ptk.RegionMaskPacker. Same emissive_groups channels on the data_nodes carriers; Blender's FBX exporter ships the export Empty's custom properties as user properties, so unitytk's EmissiveGroupController importer reads both DCCs' output identically. Verified headless (fresh Blender 5.1, test/test_emissive_groups.py, 29 checks): authoring/extend/whole-mesh membership, slot retirement + compact, overlap warning, per-corner channel values incl. accumulation and re-bake zeroing, foreign-color-attribute guard, mask + sidecar + carrier switch, and registry/carrier hygiene on teardown. Note: the channels bake needs Pillow importable in Blender's Python (present here via the user's scripts/modules); the vertex-color flow deliberately does not — pythontk's engine keeps its imaging deps optional. Release note: publish pythontk first.

  • 2026-07-28 (same-day follow-up) — Emissive Groups: two live-Blender panel bugs fixed, membership reads made O(1)-per-mesh, and the slot bookkeeping moved upstream. Mirrors mayatk's follow-up (see its changelog for the panel fixes — blanket signal.disconnect() tearing out uitk's own cellChanged wiring and emitting libpyside: Failed to disconnect (None) … in a live session, and the TooltipFormat sections= string rendering one <li> per character). Blender-specific work: (1) _member_map read membership with [i for i, d in enumerate(attr.data) if d.value] — one Python attribute access per FACE of every mesh carrying the attribute (not per member), which the panel repaid for every group on each table refresh; it now fills a numpy buffer via foreach_get in one C call. (2) bake_vertex_colors called _member_map (a whole-scene walk) once per mesh × group, rescanning everything N² times, and wrote colors one loop-corner at a time; memberships are now resolved once up front and the per-corner buffer is written with a single foreach_set (zero-initialized, which is still what clears stale membership on re-bake). (3) Authoring no longer creates the data_export carrier — only an already-published manifest is refreshed — with the check pinned in the headless suite. (4) The registry/slot logic now delegates to ptk.RegionGroupRegistry, deleting the local copy (and the dead _next_slot / _region_groups / _sanitize helpers plus unused re / defaultdict imports); schema and behavior unchanged, parity still 0 deltas.

  • 2026-07-28 — Emissive Groups panel mirrored at the polished revision (uitk TableWidget with a scrub-editable Weight column + one tb000 Bake button with an encoding option box) and exposed in tentacle. The EmissiveGroupsSlots class and the twin .ui are character-identical with mayatk's (parity sweep: 0 deltas) — only the engine below them diverges. Tentacle: ("Emissive Groups", "b027", …) in _TOOLS_ITEMS["Materials (scene)"] + a b027 launcher in slots/blender/materials.py, matching Maya. Panel wiring has no headless test on this side by construction — background Blender ships no Qt binding, so the Maya panel suite (test_emissive_groups_panel.py, GUI pass) plus the parity sweep stand in; a live-Blender pass is owed.

  • 2026-07-27 — The non-orthogonal fix is now driver-aware (mirror of mayatk's connection-preserving pass): driven transforms are skipped by default; break_connections=True removes their drivers/fcurves/constraints and fixes anyway. The Blender fix writes the whole local transform (parent_clear / visual_transform_apply), so anything that re-writes or re-composes over it on the next evaluation — transform drivers, action fcurves on transform channels, unmuted constraints — makes the bake unable to hold; there is no accurate keep-the-driver fix (same proof as the Maya side: the driver re-drives what the bake just set). get_non_orthogonal(detailed=True) gains the matching "driven" key (driver:/anim:/constraint: tags); the fix pre-classifies and skips driven objects with a message naming the driver, and only under the explicit opt-in removes them (driver_remove / fcurve removal / constraint removal — the analogue of Maya's disconnect, which likewise leaves no orphaned half-state). +9 checks in test_diagnostics.py (driver + constraint detection/skip/opt-in end-to-end) — suite PASS headless under Blender 5.1.

  • 2026-07-27 — TransformDiagnostics.get_non_orthogonal (mirror of mayatk): the public detection primitive behind FBX's "Non-orthogonal matrix support" warning. Returns the objects whose evaluated world axes are not perpendicular; detailed=True returns {object: {"skew", "cause"}} matching the mayatk contract (cause is always "inherited" here — a Blender object's own transform is Loc·Rot·Scale and cannot hold shear, so the skew always comes from a non-uniformly-scaled, rotated ancestor). fix_non_orthogonal_axes now delegates its detection to it, and the internal _has_shear is a thin wrapper over the shared _matrix_skew measurement. Kept @staticmethod like the rest of the module — the btk.Diagnostics aggregate multi-inherits these, and a classmethod would rebind and break the aggregate-identity contract test_diagnostics pins. The skew measurement itself is the new ptk.MathUtils.max_axis_skew (extracted rather than keeping a second copy of the max-abs-pairwise-cosine math beside mayatk's — see pythontk's changelog); _matrix_skew feeds the 3×3's columns in. +3 checks in test_diagnostics.py (flags the sheared child not the parent, detailed skew/cause shape, accepts names) — suite PASS headless under Blender 5.1. Exposed on the tentacle scene header (both DCCs) — see tentacle's changelog. Release note: publish pythontk first.

  • 2026-07-27 — RizomUV bridge: Edge Spacing / Tile Margin are gone as knobs — the pack gutter is DERIVED (mirror of mayatk); new UvUtils.calculate_uv_padding. The panel exposed two hand-dialed spinboxes (PACK_SPACING 0.004, PACK_MARGIN 0.002) unrelated to the gutter a DCC-side pack uses, so a Rizom round-trip and an in-DCC repack disagreed and re-packing a Rizom result silently reflowed the layout. Both tokens now come off Parameters.derived_values() — island spacing = the normalized padding (1/256 of the tile), tile margin = half of it — folded in after the user values in render_context so a stale value in a saved JSON preset can't win. blendertk gains UvUtils.calculate_uv_padding(map_size, normalize, factor) — the mayatk-mirroring name, delegating to the new ptk.MathUtils.calculate_uv_padding rather than carrying a second copy of the formula (the rule is pure ratio math, so it was extracted to pythontk; see its changelog), so both engines pack to one gutter with nothing to drift. The value is map-size-invariant by construction ((size/256)/size): 4 px at 1024, 16 px at 4096, always the same fraction of the tile. ZomPack's MarginSize / SpacingSize / PaddingSize and the ≤2021 vs ≥2022 field-name split are untouched — only where their numbers come from changed. Covered in test_rizom_construction.py (venv, 26/26): the keys aren't UI knobs, the derivation matches calculate_uv_padding at two map sizes, a stale preset value loses, and the gutter is substituted into the pack block on both Rizom generations. Parity sweep: rizom_bridge 0 deltas.

  • 2026-07-27 — EditUtils.detach_components: separate_each was silently ignored on the in-place path. With separate=False the function returned early after bpy.ops.mesh.split(), so separate_each did nothing at all — the Detach panel's per-face checkbox was dead in that combination, diverging from mayatk, where keepFacesTogether=False chips the faces apart from each other whether or not they're separated into a new object. The in-place path now runs bpy.ops.mesh.edge_split() when separate_each is set (splitting along every selected edge disconnects the faces from each other as well as from the body), leaving one loose island per face inside the source mesh. Failing-first coverage in test_edit_utils.py — an adjacent face pair (an opposite pair is already two islands, so it can't tell the two behaviours apart) checked by connected-island count: faces-together → body + 1 patch, separate_each → body + one island per face, still one object with all 6 faces.

  • 2026-07-27 — NodeUtils.uninstance(objects, freeze=True) folds the transform bake in as an optional second step (mirror of mayatk); freeze_transforms drops the uninstance param it briefly had. Un-instancing alone leaves the transform untouched, so a mirrored linked duplicate still carries the negative scale exporters object to, and transform_apply refuses multi-user meshes outright — the two steps only work together, so they now live in one call. The bake applies to every object passed, not just those that needed forking (blendertk's uninstance returns only the objects it changed, so the freeze tracks the processed list separately — otherwise an already-unique object would silently skip the bake that was explicitly requested). freeze_transforms keeps the multi-user skip (still fixing the abort-the-whole-batch bug) and names uninstance(freeze=True) in its message. Preview gains the prepare_operation(objects) hook (mirror of mayatk): a one-shot precondition run at enable, before the per-object data snapshots are taken, so it captures the prepared state. The Mirror panel uses it to break linked-data sharing once instead of re-forking inside every previewed run — see mayatk's changelog for the measured rollback failure that motivated it. The ordering is load-bearing on this side: _prior_data is the set of datablocks that already existed and anything outside it is purged as "new" on rollback, so forking after that snapshot would mark the fresh datablock for deletion and strip the object's mesh. A failing hook aborts the enable. Covered in test_preview.py (32/32): runs once at enable, not on refresh; the prepared object's mesh survives rollback; the linked sibling is untouched; a failing precondition aborts before the operation runs.

  • 2026-07-27 — BREAKING (mirror of mayatk): EditUtils.mirror drops the uninstance parameter — shared mesh data is ALWAYS made single-user first, and the panel's Un-Instance checkbox (chk005) is gone. Mirroring in place would rewrite every linked duplicate's geometry, which nothing in the UI could make obvious, so the engine handles it under the hood via the canonical NodeUtils.uninstance (which re-checks object-user counts, so mirroring a linked pair together can't copy the datablock twice and orphan the original). Migration: drop the argument. The panel's symmetrize path (cut_along_axis) breaks the link unconditionally too. Tests updated to the new contract in test_mirror_cut.py (39/39): a merge-mode mirror now forks instead of rewriting the sibling, while still doubling the mirrored object.

  • 2026-07-27 — Pinned the linked-object mirror semantics (no code change — blendertk was already safe where mayatk lost data). mayatk's separate mode destroyed an instanced source and its siblings via polySeparate (fixed there today); blendertk's separate mode gives the new half its own datablock (new.data = obj.data.copy()) and never consumes the source, so a linked sibling survives untouched. Merge modes edit the shared data in place, so every linked duplicate updates together — deliberate. Both behaviors are now asserted in test_mirror_cut.py (38/38) so the divergence can't drift, and the Un-Instance tooltip/help explain which modes the option actually affects.

  • 2026-07-27 — BUGFIX + parity: XformUtils.freeze_transforms no longer aborts the whole batch on multi-user data, and gains uninstance=False (mirror of mayatk). bpy.ops.object.transform_apply raises "Cannot apply to a multi user" and aborts every object in the call, so one linked duplicate in a selection silently cost you the freeze on all the others (the old code only unwound its bake stamps and re-raised). Multi-user objects are now detected up front — via the canonical _NodeUtilsInternal._object_users tally, not data.users, which fake users inflate — and skipped with a message while the rest of the batch bakes normally, matching mayatk's long-standing guard. uninstance=True instead forks each shared datablock first so those objects bake independently: the engine-safe finish for a linked mirror from the new EditUtils.mirror_instance (positive scale, own mesh, both halves in place). Verified in fresh Blender 5.1 (test_xform_utils.py): skip keeps the data shared and the source geometry untouched while a sibling non-shared object still bakes; uninstance=True forks, zeroes the scale, and moves neither half. test_edit_utils, test_mirror_cut, test_node_utils, test_scene_exporter all still PASS.

  • 2026-07-27 — Mirror gains an Instance output (chk007, mirror of mayatk): the mirrored half is a linked duplicate sharing the source's mesh data. New EditUtils.mirror_instance(objects, axis, pivot) copies each object (sharing obj.data) and sets matrix_world = reflection @ matrix_world, so the mirror lives entirely in the transform (negative determinant) and the halves stay live — edit either, both follow. The world reflection _local_reflection already built inline is now the shared _EditUtilsInternal._world_reflection, reused by both; pivot resolution rides the existing _plane_frame, so Instance honors the same Manip/Object/World/Border vocabulary. Panel: Instance grays out Merge Mode / Delete Original / Un-Instance and rejects the Bounding Box (center) pivot (symmetrize cuts geometry). Verified in fresh Blender 5.1 (test_mirror_cut.py, 34/34): shared datablock, correct reflection, negative determinant on the copy only, source geometry untouched, edits propagating across both halves, and the object-pivot case; test_preview.py 25/25. Parity sweep: mirror panel 0 deltas.

  • 2026-07-27 — NodeUtils.replace_with_instances gains retain_bbox_scale / retain_bbox_per_axis (mirror of mayatk). A target keeps its own scale channels when it adopts the source's datablock, so a differently-sized mesh visibly resized on instancing. retain_bbox_scale=True records each target's world bounding box before the swap and uniformly rescales it back afterwards; retain_bbox_per_axis=True fits each axis independently through the new private _NodeUtilsInternal._local_bbox_size (obj.bound_box is object-space, so the fit is rotation-independent — unlike a world-axis ratio). An axis with no extent on either side keeps its scale rather than collapsing. The swap only reaches bound_box/matrix_world after evaluation, so the pass brackets its measurements with view_layer.update() and leaves the scene settled for callers. Mirroring is never introduced or cancelled — bounding-box extents are unsigned, so every ratio is positive and a negatively-scaled (mirrored) target keeps its sign. Verified in fresh Blender 5.1 (test_node_utils.py): uniform retention, the off-by-default baseline, a rotated 1:2:4 per-axis fit, the flat-axis guard, and mirror preservation in both modes.