blendertk v0.5.31
-
2026-07-28 — The Qt-only suites now actually run:
run_tests.pyre-runs a skipped suite under the workspace.venv, which immediately surfaced a brokentest_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 printedSKIPand moved on. They were documented as.venvtargets, but nothing executed them automatically — 287 checks that only ever ran when someone remembered to run them by hand.run_suitenow takes an optionalpython=, andrun()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 thebpysuites, and these carry nobpyimport. The gap was hiding a real failure:test_blender_native_menuspatchedblendertk.ui_utils.menu_harvest.refill_qmenu, a module-level target that stopped existing when the module migrated to theMenuHarvestclass (production correctly callsmenu_harvest.MenuHarvest.refill_qmenu).mock.patchresolves its target string at call time, so the stale name passed import and collection and only raisedAttributeErrorwhen the suite was finally executed — the same moved-symbol class the ecosystem has been bitten by before. Repointed tomock.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 trailingverticalSpacerflipped from the implicitExpandingtoFixedat height 0, matching the Maya SSoT, so the collapsed output group snaps flush to the footer instead of leaving a dead band. The underlyingadjust_height_bydouble-count lives in uitk and is fixed there for both DCCs. Measured through the realBlenderUiHandlerload (Qt-only, no bpy): collapsed gap 2 px in every state, window snapping to 254 withmaximumHeightlocked when collapsed and free when expanded — identical to the Maya panel.test/test_smart_bake.py14/14. -
2026-07-28 — Shell Xform move-pad scope + snap (mayatk parity — see its CHANGELOG for the detail).
gridMoveand the<customwidgets>block are lifted verbatim from the Maya SSoT, so the two move pads cannot drift by hand-editing;parity_mapneeded no new entry (sweep clean). NewUvUtils.get_uv_bounds(objects) -> (u_min, v_min, u_max, v_max)mirrors mayatk's, read off the active UV layer via_uv_readas 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 callsptk.MathUtils.step_offset— identical logic to the Maya twin._snap_enableddeliberately does not import the Qt option-box internals: headless Blender has no Qt binding, andshell_xform_slot_check.pydrives_movethere (+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_SCOPESshape 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 surroundingfmt()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<, with a comment saying why. Caught by a static pass that evaluates everyfmt()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 /—, which Qt renders but XML doesn't predefine). -
2026-07-28 — All 42 panels reach the tooltip DSL through
self.sb.tooltipinstead of importinguitk.widgets.mixins.tooltip_mixinby path (mirrors mayatk's sweep — see its CHANGELOG for the method and the verification). 41 modules here dropped the import, including the guardedtry/except ImportErrorform inarnold_bridgeandlightmap_bakerand theirif 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.tooltipSwitchboard passthrough (uitk CHANGELOG), which also retires this module'stry/except ImportErrorTooltipFormatguard and theif 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 shoutyINCLUDING..uitext: Mirror'schk007Instance 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 measuredProgressionCurvesrun — which caught a first draft that had Bias backwards and mis-described Ease In-Out and Smooth Step. Thesmooth_step-ignores-Curve discrepancy intoggle_weight_uiis 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 publictransfer_uvs(mayatk parity).auto_unwrap(objects, method="hard"|"organic", …)mirrors mayatk's: each mesh exports to OBJ, is unwrapped byptk.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 viaget_uv_coords/set_uv_coordsand 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_shellspass.transfer_uvsis now public, closing a standing parity gap: the exact-copy /data_transferspatial-fallback pair logic the RizomUV bridge carried privately is now one implementation onUvUtils, 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.pygreen, rizom round-trip green. -
2026-07-28 (parity batch) — Thirteen Maya-parity gaps closed in one sweep: every ledgered
pendingitem built (or decisively closed), plus three rolled subsystems Blender doesn't ship. New engines:SelectionOrder(ordered OBJECT selection — a depsgraph-diff click tracker +reorder_objectssort 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 fromscene.camerawith an exactly-reversible stash + optional msgbus auto-re-apply — Maya's SetExclusiveToCamera family) backing six new cameras-menu leaves; and theNurbsUtilsCV-deformation family (bend_curvearc-length-exact circular bend,curl_curve,scale_curvature,straighten_curve,rebuild_curveuniform arc-length CV redistribution,extend_curveend-tangent extension — pure control-point math; the probed SIMPLE_DEFORM route stays refuted) backing six formerly-dead nurbs list leaves. Engine upgrades:EditUtilsgainedpropagate_normals/conform_normals/extract_reversed_faces(Maya's five polyNormal modes now 1:1 in the normals menu),combine_objects/separate_objectsgaineduninstance=True(both linked-duplicate corruption hazards VERIFIED real headless — join mutates the sibling sharing the active's datablock; separate deletes sibling faces), andcut_along_axisgainedspacing/distribution/weight_bias/weight_curvevia the sharedptk.ProgressionCurves(linear default reproduces the old even-fill exactly).UvUtilsgainedscale_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_pivotgainedmirror=(reflect the source origin across a world axis-plane before the ORIGIN_CURSOR snap).TelescopeRiggainedaim_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_renderare 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): theSelectionOrderhandler 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_curvegathers by index and removes/recreates with fresh lookups (holding spline references acrosssplines.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_allpoll-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_OPTIONSleftbake_anim_use_nla_strips/bake_anim_use_all_actionsat Blender'sTruedefaults, under whichexport_fbx_bin.fbx_animationswrites one take per action — each baked over that action's own range and start-zeroed — and, per theif 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 pinnedFalse(also inpresets/default.json), matching what mayatk's FBX path produces; per-shot takes remain the opt-in job of theapply_declared_takestask. (2)set_linear_unitandset_bake_animation_rangeused theset_/revert_pair, whichTaskFactoryfires whenrun_tasksreturns — before the write — while the FBX writer readsscene.unit_settings.scale_lengthand the scene frame range at the write. Both tasks were therefore inert. They now stage their restore viaTaskFactory.stage_deferred_restore(first-stager-wins per key, so a later widen builds on the true original) and returnNoneto keep the too-early pairing disarmed;revert_linear_unit/revert_bake_animation_rangeare gone. (3)export_data_nodestages the weight-curve proxies afterset_bake_animation_rangehas 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 pastframe_endused to ship flattened to its extrapolated value. The restores are drained from afinallyspanning 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.loghandle). Scene reads in the task manager go through a new_scene()that routes viaCoreUtils._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.ps1counted suites — it printedALL 85 SUITES PASSand 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-startupBlender per suite, never an attach) and adds: per-check tallying straight from the universalOK …/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 viaptk.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 throughptk.StatusBadge— full runs only, so a scoped run can't publish a partial count that reads as a coverage regression.Run-Tests.ps1is 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 tolinesis one failed check. Four suites were silently contributing zero:test_shot_manifest_panel,test_shot_sequencer_panelandtest_shots_slotsrun throughunittestand printed a bare verdict (they now emit(ok/attempted), withattemptedexcluding skips so a fully skipped suite reports(skipped)and contributes nothing rather than counting its skips as failures), andtest_rizom_constructionprinted a barePASSon 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_fbxsource: 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. NewEmissiveGroups.create_export_curve_proxies(): one transient Empty per keyable group with a weight fcurve, named exactly the group's manifestattr, parented underdata_export, itsscale.xkeyed 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_nodestages the proxies into the export set and registersremove_export_curve_proxieson the task manager's new transient-export-cleanup seam (run_transient_export_cleanups) — needed because task reverts run before the FBX write (the documentedexport_data_nodeordering), while the proxies must exist THROUGH the write and vanish after;perform_exportruns the cleanups in itsfinallyAND 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, andvalidate()flags leftovers from an interrupted export; a squatted proxy name (a user object already namedemissiveGroup_<x>) skips that group with a warning rather than clobbering. Unity-side the importer matches proxy node name == manifestattr, rebindsm_LocalScale.xto 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 viaFbxUtilswith the importedscale.xevaluating 1.0→0.0, squatted-name guard, leftover-validate) andtest_scene_exporter.py+4 (fullperform_exportpipeline: FBX carries the animated proxy + the manifestattrjoin 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 thedata_exportEmpty (emissiveGroup_<name>, seeded from the group default,id_properties_uilimits), keyed via fcurves (key_weightresolves the current frame window-independently through the active view layer's owning scene — nobpy.context.scene, honoring the module's screen-context AST guard);remove_keyable_weightsstrips props + fcurves via the slot-awareAnimUtilshelpers;remove_groupcleans its prop;set_defaultfollows through to un-keyed props only; the manifest records eachattr. Divergence (documented, not a reduction): Blender's FBX exporter bakes only transform / shape-key / camera channels (verified in the 5.1io_scene_fbxsource), so the curves stay in-DCC — the static prop and the manifestattrrecord still export. Same standing limitation asRenderOpacity'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_internalnever rides the FBX), the prop-side twin of the existing orphan-membership warning. Also fixed in passing: a duplicatedimport bpyinadd_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 inSTRUCTURE.md: Maya's faceobjectSets 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;facesarguments take{object_name: [face_indices]}([] = whole mesh) or the selection.bake_vertex_colors()writes theemissiveGroupscolor attribute asBYTE_COLOR/CORNER(per-corner = hard group boundaries, the Maya per-face-vertex analogue);bake_mask()harvests UVs viacalc_loop_trianglesintoptk.RegionMaskPacker. Sameemissive_groupschannels on thedata_nodescarriers; Blender's FBX exporter ships the export Empty's custom properties as user properties, so unitytk'sEmissiveGroupControllerimporter 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'sscripts/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 owncellChangedwiring and emittinglibpyside: Failed to disconnect (None) …in a live session, and theTooltipFormatsections=string rendering one<li>per character). Blender-specific work: (1)_member_mapread 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 viaforeach_getin one C call. (2)bake_vertex_colorscalled_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 singleforeach_set(zero-initialized, which is still what clears stale membership on re-bake). (3) Authoring no longer creates thedata_exportcarrier — only an already-published manifest is refreshed — with the check pinned in the headless suite. (4) The registry/slot logic now delegates toptk.RegionGroupRegistry, deleting the local copy (and the dead_next_slot/_region_groups/_sanitizehelpers plus unusedre/defaultdictimports); schema and behavior unchanged, parity still 0 deltas. -
2026-07-28 — Emissive Groups panel mirrored at the polished revision (uitk
TableWidgetwith a scrub-editable Weight column + onetb000Bake button with an encoding option box) and exposed in tentacle. TheEmissiveGroupsSlotsclass and the twin.uiare character-identical with mayatk's (parity sweep: 0 deltas) — only the engine below them diverges. Tentacle:("Emissive Groups", "b027", …)in_TOOLS_ITEMS["Materials (scene)"]+ ab027launcher inslots/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=Trueremoves 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 intest_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=Truereturns{object: {"skew", "cause"}}matching the mayatk contract (causeis 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_axesnow delegates its detection to it, and the internal_has_shearis a thin wrapper over the shared_matrix_skewmeasurement. Kept@staticmethodlike the rest of the module — thebtk.Diagnosticsaggregate multi-inherits these, and a classmethod would rebind and break the aggregate-identity contracttest_diagnosticspins. The skew measurement itself is the newptk.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_skewfeeds the 3×3's columns in. +3 checks intest_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_SPACING0.004,PACK_MARGIN0.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 offParameters.derived_values()— island spacing = the normalized padding (1/256 of the tile), tile margin = half of it — folded in after the user values inrender_contextso a stale value in a saved JSON preset can't win. blendertk gainsUvUtils.calculate_uv_padding(map_size, normalize, factor)— the mayatk-mirroring name, delegating to the newptk.MathUtils.calculate_uv_paddingrather 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'sMarginSize/SpacingSize/PaddingSizeand the ≤2021 vs ≥2022 field-name split are untouched — only where their numbers come from changed. Covered intest_rizom_construction.py(venv, 26/26): the keys aren't UI knobs, the derivation matchescalculate_uv_paddingat 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_eachwas silently ignored on the in-place path. Withseparate=Falsethe function returned early afterbpy.ops.mesh.split(), soseparate_eachdid nothing at all — the Detach panel's per-face checkbox was dead in that combination, diverging from mayatk, wherekeepFacesTogether=Falsechips the faces apart from each other whether or not they're separated into a new object. The in-place path now runsbpy.ops.mesh.edge_split()whenseparate_eachis 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 intest_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_transformsdrops theuninstanceparam it briefly had. Un-instancing alone leaves the transform untouched, so a mirrored linked duplicate still carries the negative scale exporters object to, andtransform_applyrefuses 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'suninstancereturns 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_transformskeeps the multi-user skip (still fixing the abort-the-whole-batch bug) and namesuninstance(freeze=True)in its message.Previewgains theprepare_operation(objects)hook (mirror of mayatk): a one-shot precondition run atenable, 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_datais 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 intest_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.mirrordrops theuninstanceparameter — 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 canonicalNodeUtils.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 intest_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 intest_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_transformsno longer aborts the whole batch on multi-user data, and gainsuninstance=False(mirror of mayatk).bpy.ops.object.transform_applyraises"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_userstally, notdata.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=Trueinstead forks each shared datablock first so those objects bake independently: the engine-safe finish for a linked mirror from the newEditUtils.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=Trueforks, zeroes the scale, and moves neither half.test_edit_utils,test_mirror_cut,test_node_utils,test_scene_exporterall 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. NewEditUtils.mirror_instance(objects, axis, pivot)copies each object (sharingobj.data) and setsmatrix_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_reflectionalready 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.py25/25. Parity sweep: mirror panel 0 deltas. -
2026-07-27 —
NodeUtils.replace_with_instancesgainsretain_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=Truerecords each target's world bounding box before the swap and uniformly rescales it back afterwards;retain_bbox_per_axis=Truefits each axis independently through the new private_NodeUtilsInternal._local_bbox_size(obj.bound_boxis 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 reachesbound_box/matrix_worldafter evaluation, so the pass brackets its measurements withview_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.