github m3trik/pythontk v0.11.3
pythontk v0.11.3

4 hours ago
  • 2026-09-23 -- UvPack.pack_islands keeps a gutter between islands that touch along a cut seam (geo_utils/uv_pack.py). xatlas finds charts by UV position, not index, so two islands of one mesh sharing seam coordinates welded into one chart and came back 0 texels apart; mayatk's per-shell write-back then drifted them into overlap (measured under Pack's xatlas + Preserve UV: a cut sphere's shells overlapping along every seam). Each vertex-connected island is now moved into its own cell in the engine input only; pass-through rows keep the caller's coordinates. Islands stacked exactly on each other within one mesh (mirrored halves sharing texels) now pack apart too: the same weld fused them into one chart and kept them stacked (measured for exact and mirrored stacks; whether to keep them stacked is a maintainer call, logged). Island detection hooks each triangle's lowest label onto its corners and the trees they hang from, so an island with scattered indices resolves in about a dozen vectorized passes (the corners alone took 5753 passes, 11.8 s, on a shuffled 40k-triangle strip), and a triangle indexing past its mesh's UVs is refused with ValueError up front instead of a bare numpy IndexError. Tests: test_uv_pack (the seam gutter; a scattered-index time budget and the index refusal, each red before its fix).
  • 2026-09-23 -- the WebXR preview shares by link: PreviewServer.share / PreviewBridge.share over a new, generic ShareTunnel (net_utils/share_tunnel.py, net_utils/preview/server.py, bridge.py, viewer.html; AppLauncher.spawn, NetUtils.resolves_publicly, AppInstaller's binary type). The preview was loopback-only by design -- localhost is the secure context WebXR needs. share() now gives it a view-only, live HTTPS link anyone can open: desktop, phone, or a standalone headset's browser, which gets its VR button because the link is HTTPS. Each guest renders on their own device and installs nothing; every push reaches them. A second, read-only listener (start_guest) is what the tunnel fronts, so who may write is decided by the socket, never by a header: a guest reads an allow-list (the page, the manifest, exactly what it names), every write is refused (403; a body past 4 KiB, 413 unread), the owner-only scripts (OWNER_SCRIPTS: playblast, snapshot) are withheld and the page hides Save (viewer.guest), and guests are counted per tab (guest_count) apart from the owner's has_viewer. ShareTunnel knows nothing about the preview: a registry of plain-value providers (cloudflared quick tunnels, tailscale_funnel, tailscale_serve), run through the new AppLauncher.spawn -- a child bound to this process by a kill-on-close Job Object on Windows, so a crashed DCC cannot orphan a public link -- and returned only once guests can reach it: after the provider's ready line and, for a quick tunnel's new name, a public resolver's answer (NetUtils.resolves_publicly, asked directly because an early local lookup caches the miss; measured 6-18 s of NXDOMAIN). A provider waiting on the user (Tailscale with Serve/Funnel not enabled) or a client a default-deny firewall blocked (WinError 10013) fails at once naming the step; settle offers cloudflared's download. An optional alias (PYTHONTK_PREVIEW_ALIAS / _URL) keeps a stable redirect page on your own web server. Fixed on the way: HEAD skipped the Host check; a poll crossing its tab's close beacon revived the tab for 90 s (CLOSED_LINGER, owner and guests); AppInstaller.ensure(add_to_path=True) left an already-catalogued tool off PATH; a tunnel's reset connections each printed a traceback to stderr. Hardened in review: the guest listener never lists a folder (on a root holding no page, / listed the whole serve root), takes no body past 4 KiB (its one write, the beacon, carries none; the JSON ceiling let each idle public connection hold a megabyte), and a share that unshare() / stop() interrupts while its provider starts stops its tunnel and raises instead of installing it in front of the closed listener; ShareTunnel announces under its (now re-entrant) lock, so a concurrent stop can no longer leave the alias on a dead link. Verified live through a real Cloudflare quick tunnel: the page, the guest manifest and the 403s over the public link, and a real browser there -- secure context, navigator.xr, model loaded, its close beacon crossing the tunnel. Tests: test_share_tunnel (new; fake CLIs replaying captured output; +2 in review: a stop during the announce, a hook stopping its own tunnel, mutation-checked), test_preview_server PreviewGuestTestCase / PreviewShareTestCase and two crossing-beacon cases (+3 in review: a folder listing, a guest body, a stop racing a share), test_app_launcher TestSpawn (a parent killed by os._exit takes its child), test_app_installer +2 (the regression mutation-checked), test_net_utils TestResolvesPublicly, test_preview_viewer_live +1 (a guest page, public name, secure context).
  • 2026-09-23 -- FileDependencies.remove_superseded and SceneRecords.LIGHTMAP_WRITERS: a re-bake deletes the maps it superseded, and only its own (file_utils/file_dependencies.py, core_utils/scene_records.py). A write that gives its owners new files -- another folder, another name, several maps folded into an atlas -- left the old ones on disk, read by nobody; the production project held a leftover _1 atlas no scene named. remove_superseded(before, after) deletes the files of before that no reference in after reads (per reader, not per file name, and by the filesystem's answer: a junction, subst or mapped-drive spelling of a file a reader reads keeps it -- a re-bake reaching its old folder that way had written its new map over the old path), keeps any whose name a reader found nowhere or spells only relative (a walk the host did not run could land on it), deletes only absolute paths and never a folder, and logs a file held open rather than raising. written_here(writer, scene, base) is the one ownership rule both DCCs apply first: a file is the scene's own when its writer is this scene, is "" while the scene is still unsaved (once saved, a Save As copy carries the same ""), or is gone -- never a Save As copy's still-present source, nor a relative entry with no project to read it from. LIGHTMAP_WRITERS is the private path record of which scene file wrote each lightmap, so a Save As copy never deletes what its source still reads; like LIGHTMAP_DIRS it crosses a hand-off and is re-spelled on a save into another project -- except an unsaved scene's "", which would name the scene it lands in. Tests: test_file_dependencies TestRemoveSuperseded / TestWrittenHere (an aliased spelling through a real junction, a relative path on either side, a folder, "" once saved, an unresolvable writer), test_scene_records +1 (an unsaved entry never crosses); each mutation-checked.
  • 2026-09-23 -- a GLB's shipped tangents point the way their UVs run, and none is zero-length: MeshConvert.fix_glb_tangents / GlbTangents.repair (file_utils/mesh_convert/glb_tangents.py, _mesh_convert.py). Reported as one button in a production assembly rendering bad normals in the WebXR preview, the day the DCC hand-offs began pinning FBXExportTangents / use_tspace. glTF's bitangent is cross(normal, tangent.xyz) * tangent.w and a normal map's green is image-up (decreasing V); FBX2glTF carries an FBX's tangent layer but not its binormal, where the handedness lives, and writes w = +1 on every vertex -- right on a plain UV shell, green-inverted on every mirrored one, since three.js takes a shipped TANGENT over its own derivative frame. Measured on that push: 7 of 61 tangent-carrying primitives, each wrong on exactly its mirrored triangles (the button and four light fixtures whole, 29% of a table, half of a pair of legs), plus 8 zero-length tangents on UV slivers the DCC could not orient -- a glTF violation (the Khronos validator's ACCESSOR_NON_UNIT) that three.js normalizes into NaN. Each vertex's w is now set from the way its triangles' UVs run on the normal map's own texCoord (KHR_texture_transform's texCoord where it names one) -- the rule Blender's MikkTSpace glTF export follows, checked against its output vertex by vertex (nine UV orientations, and a half-mirrored subdivided Suzanne whose 1,077 mirrored signs it restores with 0 disagreements; it parts from MikkTSpace only on a triangle wound against its own normals) -- and a zero-length tangent is rebuilt along its triangles' dP/dU (any direction across the normal where the UVs have none -- judged against the sum's own length, since a sliver's area-weighted directions are tiny, not absent); a shipped direction is otherwise kept, a vertex no triangle can vote for keeps its sign, a TANGENT shared by several primitives is voted on by all of them, and one whose readers disagree on the vertex set, or that needs a non-float or unreadable attribute, is refused. The rewrite lands in place at the TANGENT's own stride. Wired into fbx_to_glb's session beside the skin repairs, so the preview and both Scene Exporters ship it; on the production push it set 458 signs and rebuilt the 8 zero tangents on 9 primitives in 0.17 s, after which all 5,678 tangents are unit length with w of +-1 and every one of the 9,579 triangle corners points its bitangent image-up, and the button rendered through the real page lands within 1 level (p99) of the derivative-frame reference where the shipped file sat 36 off. The accessor readers it builds on learned which bytes a GLB actually holds: GlbReader.accessor and MeshConvert._accessor_elements decoded a view on another buffer, an embedded buffer naming an external file, or a compressed view straight out of the BIN -- plausible numbers, none of them the accessor's -- and now answer None, through one definition (MeshConvert._bin_view). Tests: test_glb_tangents (mirrored and plain shells, an already-right file left unwritten, the normal map's texCoord and a KHR_texture_transform override of it, every index width and a non-indexed list, a shared accessor, degenerate UVs, an interleaved TANGENT at either offset in its stride, both zero-tangent rebuilds and one on a 1e-4 sliver, the refusals including a quantized TANGENT and an index past the vertices; mutation-checked in-process), test_mesh_convert TestGlbTangentRepair (the conversion session runs it; mutation-checked) and TestBinView, test_export_verify TestGlbReader. With the hand-offs' tangent pin this closes the 2026-09-14 backlog entry (GLBs shipping no TANGENT, so three.js built every normal-mapped frame from screen-space derivatives): every normal-mapped primitive of that push ships one, 61 of 61, now as its UVs run.
  • 2026-09-23 -- the WebXR preview's baked normal relief lights a surface from its own side, and the Normals dial keeps green the way the loader set it (net_utils/preview/viewer.html, file_utils/mesh_convert/_mesh_convert.py). Reported as the preview rendering some normals wrong. The relief -- a baked texel scaled by how the normal-mapped normal faces the key light's direction against how the flat one does -- took the key as it stood, so every surface facing away from it was lit from behind: the flat response the ratio divides by fell toward zero, and measured through the real page a FLAT normal map rendered a face turned straight away from the key black (169 -> 0), 10 degrees off that at 50 and 20 off at 134, while one 20-degree bump moved a ceiling 40 levels where it moved the floor 6. The direction is now mirrored across the surface's plane wherever the surface faces away (a bake's light reached it from its front): a flat map is the pure bake on every surface, a bump reads the same on either side of the key's horizon, and a surface facing the key renders exactly as before. The lookdev Normals dial (still behind LOOKDEV_ENABLED) wrote normalScale.set(v, v), undoing GLTFLoader's normalScale.y negation on a tangent-less mesh -- every mesh this pipeline shipped before the DCC hand-offs pinned tangents -- so its first touch inverted green on every such baked normal map; it now sets the magnitude and keeps the sign, through 0 included. MeshConvert.RENDERING_POLICY states the relief as lightmappedMaterials.normalRelief (and the key light's note that its direction outlives its intensity), so the recipe says what the preview does. Tests: test_preview_viewer_live turns the fixture face every way under the key light and asserts on the pixels (a flat map at the bake level, a bump alike on mirror-image faces) -- the one fixture faced +Z, the orientation where the relief was always right -- and drives the dial's own input; test_preview_server fails a recipe that omits the relief, and now pins the page's key-light POSITION to the published one (its direction orients the relief while its intensity is 0; nothing pinned it before).
  • 2026-09-23 -- ShotEditLedger.release: a cut key's claims, step and sample, in one call (core_utils/engines/shots/shot_ledger.py). Every system cut in the two DCC sequencers released the key's step claim and its sample claim by hand, in pairs, at nine sites, plus a window form at two more -- and the cuts that released one half or neither are this day's defects: a gap collapse's merge cuts left 66 claims on frames with no key on a production assembly, and a stranded boundary sample cut or found gone kept its step claim, which _release_gap_holds would later spend restoring a pre-hold tangent onto whatever stepped key landed there. release(curve, lo, hi=None) drops every claim at lo (within eps), or within [lo, hi] ends included, and returns how many; release_step stays for the one caller that restores the types it returns. And owns_key(curve, time), the sample-claim twin of owns_step: the DCC splits ask it so a carried animator pose is not claimed on the way through a seam, and so a claim on the sample a split leaves behind can be re-pointed at the bound it serves. test_shots_core TestEditLedgerRelease (3).
  • 2026-09-23 -- a path record is spelled from the scene's OWN project, ../ chains included, and follows the scene (file_utils/_file_utils.py, core_utils/scene_records.py). Maintainer rule (2026-09-19): no absolute links in the data nodes; decided 2026-09-23: a file outside the project -- a shared sound library, a lightmap written beside its material's textures -- is spelled relative to the project the scene FILE lives in, never the session's. FileUtils.portable_path spells a ../ chain wherever a relative spelling reaches (another drive or share stays absolute; the project itself is ".", where an empty spelling read back as nothing), and rebase_portable_path re-spells a value for another base. The chain was withdrawn on 2026-09-22 because it was spelled from the SESSION's project and walked off the drive root when read under another; a base that moves with the scene cannot drift that way, so every route a record changes scenes by re-spells it. RecordSpec.paths marks the records whose values are such paths (AUDIO_FILE_MAP, LIGHTMAP_DIRS, LIGHTMAP_WRITERS); SceneRecords.rebase_paths (and SceneStoreBase.rebase_paths) re-spells them for a save into another project -- the DCC's save hook -- and with equal bases normalizes an absolute entry; a hand-off ships them absolute and lands them spelled from the receiving scene's project (TransferContext.path_base); a referenced module's arrive re-spelled from the module's (merge_carriers(source_path_base=)). A DCC store answers SceneStoreBase.project_root; project_root_of is the nearest marked ancestor of a file, else its folder (Workspace.for_path). Tests: test_file (the chain, ".", a Save As re-base; the outside-stays-absolute test replaced), test_scene_records TestProjectRelativePaths (8: the path records, a copy's writer, a re-base, normalizing, a hand-off out and in, a module merge, the store's crossings, an unsaved writer entry that never crosses), mutation-checked (each step switched off in-process fails its test).
  • 2026-09-23 -- the retired naming inputs warn, with a removal release (core_utils/export_profile.py). The Version pattern, Timestamp flag and RegEx field left both Scene Exporter panels long ago (a saved value folds into the Output Filename once), but a headless caller's tasks["version"] and ExportProfile.resolve_output_path(version_format=, timestamp=, name_regex=) were "still honoured, no removal release set": nothing counted down, and both DCC exporters threaded the three values through four signatures on every export. The maintainer's call (2026-09-23): retire them. ExportRun.from_tasks warns on a non-empty tasks["version"], and resolve_output_path on each of its three (removed in 0.12.0, not before 2026-10-23); all still fold until then. ExportRun.version_format carries the pattern to the exporter's entry point, which folds a caller's retired inputs once (fold_legacy_naming) and passes the pattern alone, so no internal call warns. test_export_profile +1 (a version pattern warns and still reaches the run; a blank one is silent), and the two tests that fold the retired inputs assert their warnings -- each failing with its notice switched off in-process. naming_report's fold warning names all three inputs (Version, Timestamp and RegEx), as both DCC exporters' log line does.
  • 2026-09-23 -- ShotStore.enclosing_bounds: the bounds a landing grows its shot to, one frame past a contiguous seam (core_utils/engines/shots/shot_model.py). Both DCC sequencers grew a shot to the whole-frame span of a key or clip dragged past its bound, so a landing on the frame the grown bound then shared with a touching neighbour took that neighbour's opening pose -- measured in mayatk through the real key-drag handler: contiguous A [0,50] and B [50,100], A's key dragged 40 -> 60 left B [60,110] opening on the dragged pose with its own a frame late at 61, and equal poses a duplicate key at 61. Touching shots share one sample, the preceding shot's, so one frame cannot hold both poses. The maintainer's call (2026-09-23), over refusing the drag or keeping a gap: the shot grows one frame past such a landing -- A [0,61], B [61,111], still touching, B opening on its own pose -- the result a mid-frame landing (59.5) already had; a landing on the existing seam steps too, and upstream mirrors it. It steps only when the moving content holds a key ON the seam (seam_keyed, answered by the caller from its curves): an unkeyed seam holds no shared sample to displace, and a step there would shift the neighbour a frame for nothing. The rule is the model's, once, so the two clip_motion twins and both sequencers' move_object_in_shot call it instead of each growing its own. test_shots_core TestEnclosingBounds (6: past the seam, onto it, upstream, off it or across a gap, the last shot, an unkeyed seam).
  • 2026-09-23 -- a deprecation's window is counted in days as well as releases (core_utils/deprecation.py). Measured on the 0.11.0 bump of 2026-09-19: Git, the four FileUtils JSON methods and ConversionRegistry.register_from_class first warned on 2026-09-04 and fell due 15 days later -- seven releases (0.9.35 -> 0.10.1) in two weeks satisfied "one release of warnings" while an outside caller had half a month to migrate. Every entry point (symbol, parameter, attributes, values, warn) takes since=, the ISO date the notice first ships, and a name is due only once the version has reached remove_in AND Deprecation.MIN_WINDOW_DAYS (30) have passed: the message says "will be removed in pythontk 0.12.0, not before 2026-10-23", and report marks a name that is due by version but not by date HELD until <date> instead of EXPIRED. The rule is one function, Deprecation.window_expired, which generate_api_registry.py loads off disk the way it loads version_key, so the static --check and the runtime roster cannot disagree. A remove_in naming a patch release (0.12.1) is refused at construction, as an unparseable one is: a patch never removes a name. A notice naming no since keeps the version rule, so every existing call works unchanged; the new gate test requires it for pythontk's own notices, and all 34 already shipped (pythontk 3, mayatk 18, blendertk 13) were backfilled from the first release tag containing each notice's introducing commit (every one first shipped in today's releases). test_deprecation +11: day 15 held and day 30 expired, time alone never expires a name, no since keeps the version rule, the message's date, a malformed date and a patch remove_in refused, the roster's HELD vs EXPIRED, the shared rule, every entry point recording its date, and the pythontk gate; fixtures moved from 9.9.9 to 9.9.0.
  • 2026-09-23 -- SceneRecords.LIGHTMAP_DIRS: a private home for the lightmap folder hint (core_utils/scene_records.py). Each baked object's lightmapInfo marker carried its map's folder as dir, and a marker is an object attribute that rides every FBX as a user property: build-setup data on the deliverable (maintainer rule, 2026-09-19; measured, one committed cube's FBX carried its absolute authoring folder). The folder moves to this record, {lower-case map file name: folder}: PRIVATE, so no deliverable carries it; portable, so a scene pulled across the bridge still finds its maps; respell=False, because its keys are file names rather than scene names; UNION merge, with this scene's entry winning. mayatk and blendertk write and read it, and lift the legacy field off old markers. test_scene_records +2 (it never ships and it crosses a hand-off; a crossing keeps this scene's folder and never respells a key, mutation-checked).

Don't miss a new pythontk release

NewReleases is sending notifications on new releases.