Skip to content

pythontk v0.9.16

Choose a tag to compare

@m3trik m3trik released this 13 Aug 00:42
· 5 commits to main since this release
  • 2026-08-12 — Three defects in the same day's texture-output work, all caught in the release review before shipping. Each is a regression introduced by that work rather than an old bug, and each was measured rather than argued.

    A .jpg write with optimize=True raised OSError: broken data stream. In optimize mode libjpeg needs one buffer sized for the whole encoded image, and Pillow guesses it from the pixel count — 2*w*h at quality >= 95 — on the assumption of 4:2:0 chroma. The new JPEG defaults write 4:4:4 (subsampling=0), which is right for a texture library (chroma subsampling is what turns a normal map's X/Y vectors to mush) but carries full-resolution chroma, so a high-frequency map encodes past the guess and Pillow raises instead of growing the buffer. MapOptimizer.optimize_map passes optimize=True on every save, so this broke the optimizer's entire JPEG path for exactly the detailed maps it exists to process. Measured on random-noise RGB at q95/4:4:4: 256² encoded to 159 KB against a 131 KB budget, 1024² to 2.48 MB against 2 MB — every size tested failed. ImageFile._save takes max(MAXBLOCK, bufsize), so _sized_encoder_buffer raises that global for the duration of the save and restores it in finally; the bound is the raw pixel size, which a JPEG cannot exceed at any quality. A second test pins the restore, because it is process-wide state and this is a library.

    Choosing a JPEG container wrote normals and packed maps lossy in silence. resolve_quality's own docstring says the safety gate is what stops "a batch run from destroying every normal map in a folder because the operator set one dropdown" — but it returned early whenever no explicit lossy_quality was given, so it never consulted is_lossy_safe on the path a user actually takes. An explicit q90 on a normal map was refused aloud; picking .jpg off a format menu wrote it at the q95 default with warnings: []. JPEG has no lossless mode, so naming it is the request — the new ImgUtils.ALWAYS_LOSSY_FORMATS marks that subset, and WebP is deliberately excluded because it has a lossless mode that _apply_lossy_kwargs already selects when no quality is asked for. There is nothing to veto here (the caller asked for JPEG and JPEG cannot be written losslessly), so it reports rather than refuses, which keeps a batch run auditable. One existing test asserted warnings == [] for an ORM into .jpg; it is about channel-drop attribution ("warning twice for one drop would misattribute it"), so it was narrowed to that axis rather than the new warning being suppressed to fit it.

    GIF lost its adaptive palette. The new _CONTAINER_MODE_FALLBACKS coerces RGB → P before the write, replacing "let GifImagePlugin quantise on the way out". A bare convert("P") takes Pillow's default 216-colour WEB palette where the plugin quantised adaptively: measured on a gradient, 49/255 max and 19.4 mean round-trip error against the plugin's 17/2.76 — visible banding from a change meant only to pick a mode. Now ADAPTIVE, restoring the plugin's numbers exactly. Gated on the source mode: ADAPTIVE routes through quantize(), which accepts only RGB and L and raises ValueError: image has wrong mode on the LA / RGBA / I;16 entries the same table maps to P — caught by test_table_matches_what_pillow_actually_writes, which walks every row. Those modes keep the plain convert, i.e. exactly their previous behaviour.

    Also: docs/webxr_preview.md still documented the removed scene-wide scene.environmentIntensity policy and asserted that per-material control was unsafe because dispose would free the shared texture — which is precisely what the same day's viewer change implements and guards, so the doc contradicted both the shipped code and its own tests. Rewritten to the per-material contract. without_locate_hints' docstring now states plainly that the no-hint path returns the caller's object (pinned deliberately by test_without_locate_hints_passes_clean_snapshots_through to avoid churning every clean export) and that the guarantee is "never mutates", not "always copies". Full suite: 3029 tests, 3017 passed, 12 skipped, 0 failures.

  • 2026-08-12 — The deliverable stops shipping the authoring machine's directory tree, and starts shipping the lighting recipe it was approved under. Two halves of the same gap: what a hand-off carries that it should not, and what it does not carry that it must.

    The lightmap publisher stamps an absolute authoring directory (dir) into the scene-wide manifest and into every per-object marker, so apply_glb_lightmaps can find the EXRs on the machine that baked them. Once those maps are encoded and embedded, that path has no reader left — but it shipped anyway. Measured on a client hand-off: 49 copies of O:\Dropbox (Client)\…\sourceimages inside one GLB, disclosing the drive layout and the client's name to whoever received the file, and resolving to nothing on their disk. _strip_locate_hints now drops it once the maps are in the file, which is the only point where "dead" is provably true — not at export (the applier still needs it) and not never (it ships). Gated on a successful embed, so a run that bound nothing leaves the hints intact for a retry. Surgical: only that one key goes, so map/uv_set/intensity/scaleOffset still read, and a basename alone stays resolvable against search_dirs or the GLB's own directory. Verified against the real deliverable — 49 path-like strings to 2, and the 2 that remain are the scene_sidecar provenance keys, which are load-bearing by documented contract (they join a section entry to the textures map) and deliberately untouched.

    Two containers, one rule: _strip_locate_hints handles the parsed glTF, and the new public without_locate_hints handles the DataNodes.dump() snapshot that mayatk and blendertk each write their export sidecar from. Both live here, with the key names, rather than as a package-local copy in each DCC — that would have been two identical methods and a seam for a second hint key to be added to one and missed in the others.

    The test for it asserts on backslash-stripped text. These paths sit inside JSON strings nested in a JSON document, so the separator arrives re-escaped and a plain substring search reports CLEAN on a file that is still leaking — the exact false negative that hid this from an earlier audit pass.

    The other half: handoff.rendering, a new section of the envelope carrying the reference viewer's lighting setup as data — tone mapping, the PMREM'd RoomEnvironment and its level, the key light and the condition it switches off under, and the per-material envMapIntensity a lightmapped material is held at. A baked asset needs this more than an unbaked one. Its lighting is already in its textures, so the viewer's own rig has to be withheld by exactly the right amount; every number is a measured compromise, not a default. Without it a recipient reasonably adds a normal key light, blows out every baked surface, reads it as a bake regression, and sends it back to the baker — where nothing is wrong. test_preview_server pins each value against the viewer's own JavaScript literals, which no Python test executes: a published recipe that quietly stops matching the reference render is worse than none, because it is followed exactly and still yields a different image. Full suite: 3023 tests, 12 skipped.

  • 2026-08-12 — The WebXR preview dims the environment PER MATERIAL, so a partly-baked scene stops under-lighting its un-baked props. A lightmapped material already contains its diffuse lighting and wants the viewer's own light out of the way; a prop that carries no bake is an ordinary PBR surface and wants the full environment. The policy was scene-wide (scene.environmentIntensity), so it dimmed both — and scenes are routinely partly baked (measured on a production room: 51 of 57 materials), leaving every un-baked prop cooler than it should be for a reason invisible from the model.

    The reason it was scene-wide is real and is now solved rather than accepted: three.js overwrites a material's envMapIntensity from the scene value for exactly the materials this affects (isMeshStandardMaterial with envMap === null, which is every GLTFLoader material), so setting the property alone does nothing. The opt-out is that envMap === null clause — a baked material is given the shared session environment as its own envMap, which costs no extra upload (it is the texture the scene was already lighting with) and stops the override. That in turn exposed a latent bug in disposeModel: it disposes every texture-valued property of every material, which would have freed the session-owned PMREM on the next push and left the viewer unlit from then on. It now spares scene.environment — the model was never its owner.

    The key light stays scene-wide and still goes off the moment anything is baked. It briefly gained a "only when FULLY baked" gate here, on the theory that un-baked props needed it; that was wrong and was reverted the same day. A scene-wide term cannot be spared the geometry it contradicts — a 0.9 directional light would land on surfaces whose lighting is already in their lightmap — and because a room is routinely partly baked (51 of 57 materials), the gate would essentially never open, so every baked room would blow out. It also reads as a baker regression: re-baking with the Maya lights turned down changes nothing, because the extra light is added downstream of the EXR. The un-baked props the gate was meant to protect are covered by the per-material split itself, which now holds them at the FULL environment; being stuck at 0.25 is what made them look unlit in the first place. countMaterials remains, feeding the HUD's lightmapped/total count.

    Per-model state is reset at load, before apply, so a superseded or failed load cannot leave the previous model's materials driven by the toggle. The three material-collection walks share one materialsOf(node) helper rather than three hand-rolled copies of the single/array/absent normalization. Tests re-pinned to the new contract (the old one asserted the scene-wide line verbatim) plus the dispose guard. Full suite: 3009 passed, 12 skipped.

  • 2026-08-12 — A base-colour texture written by the sidecar now neutralises the converter's tint, instead of shipping the room at half its authored albedo. set_glb_base_color rebound the texture correctly and left baseColorFactor alone; glTF multiplies the two. Measured on a production room (StingrayPBS → FBX2glTF 0.13.1): the FBX carries no DiffuseColor for a Stingray material, so every material reached the GLB at a flat [0.5, 0.5, 0.5, 1] and the deliverable rendered at half the authored albedo — with the envelope reporting base_color: 1 of 1 applied, because the texture half genuinely had been. The Maya side settles that the 0.5 is not a choice: it is StingrayPBS's -dv 0.5 attribute default, inert under use_color_map, never overridden by a setAttr in the scene.

    Now: a spec carrying a texture and no color resets the factor's RGB to 1,1,1 — the texture is the albedo, so anything else tints what it was only meant to carry. An explicit color still wins (an authored tint is intent; only the converter's fallback is reset), alpha is preserved so a transparent material is not silently made opaque, and an already-neutral factor is left untouched so unaffected materials stay byte-stable. The fix lands before apply_glb_lightmaps clones the material per atlas rect (copy.deepcopy), so the 46 clones a room actually wears inherit the corrected factor rather than 46 copies of the tint. Tests pin all three branches. test_mesh_convert: 100 passed, 1 skipped.

  • 2026-08-12 — CancelScope: cancelling a long operation no longer means throwing an exception at it from another thread. ExecutionMonitor's Esc-cancel had never worked reliably inside Maya, and the reason was structural rather than a bug to find. Its monitor thread polled GetAsyncKeyState and called _thread.interrupt_main(), which does not stop anything — it sets a flag that raises at the next bytecode boundary on the main thread. A native cmds call has no such boundary, so the cancel could not land until the operation finished; and because a pending interrupt cannot be revoked, one armed in the race between the slot's finally and the poller's next sample materialized inside whatever ran next — a scriptJob, an idle callback, the next slot. That is the classic innocent-bystander async-exception failure, and it explains the symptom the old code was reported with: "Esc does nothing, and later something unrelated dies." Detection was equally unsound: GetAsyncKeyState is system-wide (Esc in a browser counted) and a single sample fired the cancel despite the documented press-and-hold contract, so Esc's dozen ordinary DCC meanings — dismiss popup, exit tool, stop playback — were all cancel requests.

    The replacement is a plain cooperative primitive: something sets a flag, and the operation notices at a point it chose. CancelScope holds that flag with two ways in — push (cancel(), thread-safe and idempotent, used by the dialog button) and pull (source callables polled by the operation's own thread at its checkpoints, which is the only shape a DCC can offer: Maya's MComputation.isInterruptRequested() peeks the OS input queue for Esc without pumping an event loop, so it works during exactly the synchronous stretch where a Qt shortcut cannot be delivered). Sources are polled only from the owner thread — a DCC API call from a worker thread crashes the host — and throttled by poll_min_interval so a tight check() loop stays cheap. Both consumption styles are first-class and feed one scope, so a loop that breaks on tick() and a helper that lets checkpoint() raise are the same cancellation: OperationCancelled derives from BaseException for the reason asyncio.CancelledError does, since tool code is full of broad except Exception that would otherwise swallow it. activate() publishes the scope through a ContextVar, so ptk.CancelScope.check() gives a helper nested arbitrarily deep a checkpoint with no signature churn and no import of the UI layer — and a new thread starts with an empty context, which is what keeps pull sources on the owner thread by construction rather than by discipline.

    ExecutionMonitor keeps what it was always good at (the threshold → warning-dialog escalation, the spinner subprocess, the external watchdog) and hands cancellation to a scope: pass cancel_scope= and the dialog's Cancel and the Esc poller only set the flag. The async-exception paths survive only behind the explicit Force Stop / Force Quit buttons, where the user has opted into an unsafe stop knowingly; callers that pass no scope keep the old behaviour rather than silently losing cancellation. Esc detection itself is now a deliberate gesture: escape_hold_source() requires a sustained hold and is_foreground_process() (GetForegroundWindow + GetWindowThreadProcessId), which removes the whole system-wide-false-positive class; it is stateful and usable both from the monitor thread and as a scope pull source, so Esc keeps working even when nothing is pumping events. The dialog also stops over-promising: it reads has_ticked, and an operation that has never reached a checkpoint is told it has no cancellable point rather than being offered a Cancel that would sit unconsumed. test_cancel_scope.py 34 new tests; test_execution_monitor.py 49 → 54, with the two that pinned single-sample-Esc-raises-KeyboardInterrupt rewritten to the new contract and new coverage for the hold requirement, the foreground gate, dialog-cancels-scope, and the honest no-checkpoints dialog.

    Three of those tests pin traps found reviewing the primitive rather than using it, all in re-entrant activation and all mutation-checked against the pre-fix code. Holding a single activation record meant with scope: nested inside another with scope: overwrote the outer record — and dropping that reference finalized the outer generator immediately, running its finally (ContextVar reset, parent unlink) while the outer block was still executing: the scope stopped being ambient part-way through itself, so a checkpoint after the inner block silently found nothing and never cancelled. __enter__ keeps a stack now, activate() restores the prior parent/owner instead of blanket-clearing them, and re-activating an already-active outer scope (A → B → A) skips the parent link rather than forming a cycle that made cancelled recurse until the interpreter gave up.