-
2026-08-15 — PyPI index-propagation cover widened from 3x30s to 8x30s (
.github/workflows/publish.yml). The post-publish step installs the freshly uploaded wheel WITH dependencies, to prove its pins resolve on PyPI exactly as users will install them. Its retry budget was measured too short on 2026-08-13: the simple index lagged past the old ~1.5-minute window and the release cascade aborted on "No matching distribution found" for an upstream published minutes earlier in the same run — a red run on a wheel that was fine. Eight attempts give ~3.5 minutes of cover; the retry only absorbs propagation lag, so a genuinely unresolvable pin still fails exactly as before, just later. Landed identically across pythontk / uitk / mayatk / blendertk / tentacle, since the cascade fails as a unit. -
2026-08-15 —
MeshCleaner→MeshOps: the PyMeshLab wrapper had never executed (dep uninstalled and undeclared everywhere), and its one weld step was a silent no-op even on paper.mesh_cleaner.pybecomesfile_utils/mesh_ops.pyon theUvUnwrapregistry design: typed classmethods (measure/compare/clean/remesh/decimate/bake_vertex_color) over a curatedOPS: Dict[str, OpSpec]single-filter vocabulary, plusapply()as the unvalidated escape hatch and a context-managedsession()so composed ops skip the load→save→reload round-trip. Availability is the Style-B contract (resolve(required=)/available()/install note naming the newpythontk[mesh]extra — the package's first[project.optional-dependencies]table, floored atpymeshlab>=2023.12for thePureValuerename). Every filter name and parameter was verified by enumeration against an installed pymeshlab 2025.7 rather than assumed, which surfaced the bug the rename exists to mark:clean()passedmerge_distance(documented as a distance, default1e-5) throughPercentageValue— 0.00001% of the bbox diagonal, sub-micron on any real asset — so the close-vertex weld merged nothing, masked by the exact-duplicate pass before it; live-reproduced (jittered duplicates: 5124→5124 verts under the old call, 5124→2562 underPureValue) and pinned by a failing-first regression test.measure()/compare()return flat gate-safe dicts — numeric-or-None, never a fabricated 0 (a 0 falsely passesmax_*QcGate rules), and nomin_/max_substrings in metric keys becauseQcGatestrips rule prefixes with an unanchored replace (hausdorff_peak, nothausdorff_max). CI now installs pymeshlab so the real paths run instead of skipping. Read and write support are gated separately (SUPPORTED_EXTSvsSAVE_EXTS, both probed: glTF/GLB load — verified with a hand-rolled minimal GLB — but have no exporter), and every op validates its output extension before any work runs, soclean("scan.glb")fails in milliseconds with an actionable error instead of after the whole chain with a rawPyMeshLabExceptionat save time. 28 tests added (test_mesh_ops.py); full suite 3188 collected / 3173 passed / 0 failed. Breaking: one in-repo consumer (extappsphotogrammetry) migrates in the same change; recorded inAPI_CHANGES.md. -
2026-08-15 — Audit fixes: 16-bit maps are rescaled instead of clipped to white, palettised alpha survives KTX2 staging, the optimizer twins agree on containers that cannot hold 16 bits, and
assesscan read back the KTX2 it just wrote. Thirteen defects found in a full-package audit of the uncommitted texture/doc-audit/CLI work — none of which the (green) suite caught. Each is pinned by a new failing-first test; 40 tests added, full suite 3156 collected / 3142 passed / 0 failed.The same clip-vs-rescale trap on the float path (
ImgUtils.convert_f_to_l). Found by critiquing the fixes above:MapOptimizer._coerce_modereduced a mode-Fsource with a plain convert, and Pillow implementsF->Las a truncation — so a float map's 0..1 data sent every texel below 1.0 to 0. Probe-proven end to end: a 0..1 float-TIFF height ramp came out ofoptimize_mapas a 2-value, essentially black 84-byte PNG, reported as a successful -99% optimization, withassesspredicting a cleanLand no test covering the path at all. NewImgUtils.convert_f_to_lis the float twin ofconvert_i_to_land lives beside it: 0..1 is the float convention's full scale exactly as 0..65535 is the 16-bit one, so it maps onto the whole 0..255, and out-of-range data is clamped rather than normalized — dividing by the actual maximum would silently tonemap, changing every texel's meaning to make one bright one fit (genuine HDR that needs its range kept still wantsencode_hdr_for_web, which returns the scalar). Clamping also keeps the optimizer twins in agreement, where refusing the mode outright — the choiceKtx2Encoder._stage_imagecorrectly makes for a direct encode — would have made the writer raise on a predictionassesshad already called clean. The two compose: an EXR intooutput_type="ktx2"now reduces toLin the optimizer and reaches the encoder as an 8-bit image, so the refusal only fires where nothing reduced it.Both reduction twins now dispatch on the array dtype rather than trusting the caller to have picked the matching entry point, because each destroys the other's input if its name is taken literally:
convert_f_to_lon 8-bit data drives every value >= 1 to white, andconvert_i_to_lon 0..1 floats — a latent trap that predates this work — never exceeds 255, so it skips the rescale and rounds straight to 0/1. Both collapsed a 256-step ramp to two values; either entry point is now safe for either dtype.Two silent data-loss bugs in the day-old KTX2 path.
Ktx2Encoder._stage_imagereduced a high-bit-depth source with a plainim.convert("L")— and Pillow implementsI;16->Las a clip at 255, not a range rescale, so a smooth 0..65535 height ramp staged as 2 unique values, 99.6% of them pure white.OutputTemplates._PRECISION_16authors Height/Displacement/Bump at 16-bit and_save_ktx2bypassessave_image's bit-depth handling, so ktx2 was the one container the profile system could hand a liveI;16image to. The reduction now goes throughImgUtils.convert_i_to_l, which already owned this rule package-wide (numpy, ÷257 full-scale) — the privatepoint-based rescale first written here was not only a duplicate but crashed outright onI;16B, the mode Pillow returns for a big-endian 16-bit TIFF, becausepointrefuses byte-order-qualified modes. Separately, a palettised source was staged through the mode-onlyeffective_modetable, which mapsP->RGBand destroys tRNS alpha whiledropped_channelsreports no loss (modePhas noAband to notice); both Basis codecs carry alpha, so nothing forced the drop.P/PAnow route through the existingImgUtils.depalettize_image.The optimizer twins disagreed on every container that cannot store 16 bits.
plan()'s high-precision tolerance was gated on the resolvedOutputSpec's bit depth but never on the container actually being written —plan()was not even passedoutput_type— and since Height/Displacement/Bump resolve toOutputSpec("png", 16)under every built-in profile, the tolerance was live for any output type. Probe-proven:optimize_map(<16-bit Height>, output_type="dds")raisedOSError: cannot write mode I;16 as DDSwhileassesspredicted a clean no-op; tga silently widened a linear height map to 24-bit RGB (+4434%); a mode-Isource additionally raised on tga and jpg. All five wrote valid single-channelLat HEAD, so this was a regression, not an inherited gap.plan()now takes the container and consults_HIGH_PRECISION_CONTAINERS— which records the mode each container actually stores, measured by save-and-reopen, rather than being derived from_CONTAINER_MODE_FALLBACKS(that table lists only modes whose stored form differs, so every container missing from it reads as "keeps I;16", which is the bug). A container with no entry gets no tolerance and coerces to the map type's own target mode —L, never RGB.assessandoptimize_mapnow agree on mode and dimensions for dds/tga/jpg/png/webp across bothIandI;16sources, pinned by a twin-agreement walk.assesscould not read its own deliverable. A.ktx2written byoptimize_mapfed straight back in returnedFailed to read image: cannot identify image fileand an emptypredicteddict — so the scene exporters' optimize-textures gate, which by design assesses the staged output the task just wrote, reported a read failure for every KTX2 map it delivered, and any caller readingpredicted["width"]got aKeyError. NewKtx2Encoder.read_headerparses the fixed-layout KTX2 header (identifier +vkFormat/typeSize/pixelWidth/pixelHeight/pixelDepth), givingKTX2_MAGICits first production user — it was previously referenced only by tests, both of which skip wheretoktxis absent.Spec conformance, state leakage, and exit codes.
optimize_glb_texturesin KTX2 mode rewrote an image's mimeType to the non-coreimage/ktx2fromreplacements(every decodable image) while gating theKHR_texture_basisudeclaration onbound_basisu(set only when a texture rebinds), so a GLB whose images are sampled by nothing shipped a non-core mimeType with no extension declaring it; the encode is now gated on the image actually being sampled, keeping the file valid either way without forcingextensionsRequiredonto an asset that uses none.PreviewDeliverer.texture_formatwas documented as a per-push knob but lived as instance state on a class-level shared deliverer with no per-request override, so one KTX2 push stuck process-wide for every bridge in the session — now request-scoped like the adjacentopen_browser, falling back through the instance default toWEBP(a falsy default would otherwise hand the optimizerNone, which raises into the broadexceptand silently ships an unoptimized GLB).MapOptimizer._snap_potvalidatespot_modeinstead of silently takinground()for any unrecognized string.python -m pythontk <HelpMixin target> <missing member>returned 0 while the same mistake on a non-HelpMixin target exited 1 — the message went to stdout, so--source > file || fallbacknever took the fallback and wrote the error text into the file; it now probes the member with the mixin's owngetattr(cls, name, None)predicate (not a string match on the message), prints to stderr, and exits 1, with the--jsonerror payload shape preserved on stdout.DocAuditwent blind on wildcard imports.from <mod> import *passed the literal"*"togetattr, so any wildcard in an audited block always reportedname not found— and because the star names never entered the namespace, every later reference in that block was silently skipped as an unknown root, i.e. the audit failed open on exactly the blocks that use a wildcard. The star surface is now expanded the way the interpreter does (__all__when defined, else the publicdir()), and that expansion is guarded: reading it runs the module's own code, so a non-iterable__all__, a lazy__getattr__raising for an uninstalled extra, or a custom__dir__that blows up now leaves the surface unknown instead of taking the whole docs gate down with a traceback.Also:
refresh_export_view's dropped empty-store guard — deliberate (mayatk's companion entry documents the stale-takes fix it enables) but shipped with zero coverage — is now pinned by six tests;test_assess_surfaces_the_etc1s_refusalwas asserting an unrelated lossy-quality refusal and is renamed to what it covers, with a real ETC1S-through-a-profile case added; thetest_net_utilsRDP test asserted against atempfile.mkstemppatch that no longer bound after theTempArtifactsmigration (it passed while its redirect was a no-op and leaked a live.rdpinto the real temp dir) and now locks the migration in; and two dead locals went (gltfin_embed_image,import tempfilein_net_utils). -
2026-08-14 — Review fixes: the 16-bit grayscale tolerance is now spec-gated, a missing KTX2 encoder always raises instead of sometimes shipping unoptimized,
toktxgets a subprocess timeout,doc_auditsurvives a broken optional-dep import, and--indexrejects a target instead of dropping it. Six defects found in review of the same day's texture-output/doc-audit work, each pinned by a new failing-first test.MapOptimizer's high-precision (I/I;16) mode-coercion tolerance was keyed only on the target mode ("L"), so it tolerated 16-bit input for every L-target map type — but only Height/Displacement/Bump actually carry a 16-bitOutputSpec; Roughness/AO/Metallic stay 8-bit and were silently shipping 16-bit PNGs withassessreportingrecommended=False.plan()now resolves the map type'sOutputSpecbit depth (viaOutputTemplates.resolve, gracefully falling back toDEFAULToutside a profiled run) and only tolerates the high-precision modes when that resolved spec is itself >= 16-bit.PreviewDeliverer.deliver()wrappedMeshConvert.optimize_glb_texturesin a broadexcept Exception, so atexture_format="KTX2"push with notoktxinstalled silently shipped the unoptimized GLB — contradictingdocs/webxr_preview.md's promise that the push raises with the install URL.deliver()now callsImgUtils.resolve_ktx2_encoder(required=True)before the try-block, so the fix-shapedFileNotFoundErroralways propagates for a KTX2 push.ImgUtils.resolve_ktx2_encoder(required=True)itself had a latent fall-through: ifKtx2Encoder.available()said False butKtx2Encoder.resolve_toktx(required=True)unexpectedly succeeded, it returnedNoneinstead of raising — now it raises the fix-shaped error unconditionally. The two existing tests that only mockedavailable()(silently non-deterministic on a machine wheretoktxreally is installed) now also mockresolve_toktxto raise.Ktx2Encoder._runhad no subprocess timeout, so a hungtoktxblocked the calling thread forever — fatal for a DCC's single-threaded UI. New constructortimeoutparam (default 300s), passed tosubprocess.run; aTimeoutExpiredis re-raised as the same fix-shapedRuntimeErrorstyle the method already uses for a non-zero exit.DocAudit.audit_code's import-probing caught onlyImportError, so an optional-dep module that raised anything else at import time (RuntimeError/OSError— a missing native library, a misconfigured environment) crashed the whole audit instead of leaving that one name "unknown", same as a genuinely missing package. Now catchesExceptionon both theimportandfrom...importprobes.python -m pythontk sometarget --indexsilently dropped the target and ran--indexas if it had never been given — a typo'd invocation looked like it did something unrelated instead of surfacing the mistake. Nowparser.errors on the combination.Also: two
open(path, "rb").read()calls intest_mesh_convert.py's KTX2 no-partial-rewrite test leaked file handles — moved towithblocks.test/test_map_optimizer.py test/test_img.py test/test_mesh_convert.py test/test_doc_audit.py test/test_main_cli.py: 448 passed, 1 skipped (toktx-gated integration test), 34 subtests passed. -
2026-08-14 — 16-bit grayscale no longer planned down to 8-bit L: the writer and its dry-run twin agree again (
MapOptimizer_HIGH_PRECISION_EQUIV). Probe-proven divergence:optimize_mapunder a profile whose Height/Displacement/Bump spec is 16-bit (_PRECISION_16) writes a PNG that reads back as PIL modeI;16— whichplan/assessthen flagged for anI;16 -> Lcoercion on every subsequent look, so the twins disagreed about the optimizer's own output forever (and the plan, if executed, would flatten to 8 bits the very precision the spec had just paid for, before the save re-expanded it emptily to 16). A high-precision same-layout mode (I,I;16where the target isL) is now tolerated in both the map-type coercion step and the strict-mode step, defined once in_HIGH_PRECISION_EQUIVand folded into_MAP_TYPE_TOLERATEDfrom it. Surfaced by the scene exporters' new optimize-textures gate, which assesses the staged output the task just wrote and could therefore never converge on a height map. Two new tests: anI/I;16Height plans zero ops, and — the invariant itself —optimize_mapoutput under every shipped profile assessesrecommended=Falseunder that same profile. Full suite green. -
2026-08-14 —
MapOptimizer.optimize_map/assessexposepot_mode, so a caller-resolved budget can request the never-grow snap. The "down" snap existed but was reachable only by activating a whole output profile withenforce_budget— a caller that resolves aDeliveryBudgetitself and passes it as plainmax_size/force_pot(a UI with its own clamp control, like the Map Converter's — whose per-axis "nearest" snap underenforce_budgetis a known backlog item) was forced onto "nearest", where 96 rounds UP to 128 and the enforcement inflates the asset it exists to shrink. The parameter defaults to None = the existing derived behavior ("down" only when the POT rule came from an enforced profile budget), and an explicit value outranks the derivation on both twins — same precedence rule, and the same single-resolution reasoning, as every other advisory-tier argument. Two new tests pin the no-profile "down" path across assess/optimize_map and the explicit-beats-derived precedence; full suite 3106 green. -
2026-08-14 — Docs stop rotting silently, and the API map ships with the wheel (
DocAudit,python -m pythontk --index). The README-accuracy pass earlier the same day found the examples correct — but only by manually executing them, which protects nothing going forward.core_utils/doc_audit.pymechanizes that sweep as a general primitive: extract fenced code blocks from markdown, then validate every attribute chain rooted at a known namespace and every keyword argument against the live package (astwalk +inspect.signature), skipping what an example legitimately fakes (locally defined names, unknown roots, non-introspectable callables). It deliberately audits binding, not behavior — the literal outputs the README claims are pinned by a companion test that asserts each value both computes and still appears in the document, so behavior drift and doc drift each fail their own assert.test/test_doc_audit.pygatesdocs/README.mdplus the three new subpackage READMEs; the class is root-exported so downstream repos can gate their own docs.--indexcloses the other reachability gap: the generatedAPI_INDEX.mdlives at repo root and never ships, so a pip-installed agent had the introspection CLI but no map of what to ask it about. Rather than shipping a second copy of the file (an artifact that can drift), the flag computes the listing from the live package — both tiers, since__all__deliberately excludes the wildcard-exposed bare aliases (filter_listet al., per_populate_all's contract):rootrows from__all__,barerows from the resolver's publishedMETHOD_TO_MODULE. Every row resolves tomodule.qualname, which makes each line a validpython -m pythontktarget — the index and the inspector compose.--jsonemits structured rows; a symbol that fails to resolve (optional dep) degrades to anunresolvablerow instead of killing the listing. Newtest/test_main_cli.pycovers both tiers, the feed-back-in property, and the bare-target requirement staying intact. -
2026-08-14 — README architecture: subpackage READMEs for the three packages a table row can't hold, plus an accuracy pass on the front door.
core_utils/,file_utils/, andnet_utils/gain co-locatedREADME.mds (module map by cluster + the design arguments that previously lived only in docstrings: temp-artifact lifetime policies, the RPC two-halves/verbatim-copy contract, the three hand-off shapes, the USD zero-dep floor,workspace.melas a shared format).docs/README.mdstays the pitch: its package table now links the three, rows caught up with the tree (PlateEmitter/UvPack,Ktx2Encoder+ atlas tooling,TempArtifacts/Workspace/USD,Weights,CancelScope), the optional-deps list gainedxatlas,toktx,paramiko,keyring, and the deep infrastructure bullets collapsed to pointers. Every code example was runtime-verified against the installed package — all literal outputs matched; the one false claim found, "plugin discovery never executes plugin code," is corrected (AST enumerates, but modules are imported to resolve class objects).core_utils/__init__.py's cluster map caught up withcancel_scope/status_badge/step_toggle. Workspace docs sweep: clean. -
2026-08-13 — KTX2/Basis Universal delivery lands across the texture chain (
Ktx2Encoder,save_image,MapOptimizer,optimize_glb_textures, the preview viewer). The GPU-memory half the web budget was missing: WebP shrinks the wire, but every image still decodes to raw RGBA on the GPU — measured on a delivered preview GLB, 9.5 MB on the wire held ~740 MB of RGBA+mips, and that, not download, is what limits a headset. KTX2/Basis stays block-compressed GPU-side: one transcoder turns either codec into ASTC on a standalone headset, BC7/DXT on desktop, ETC2 as the floor — so the ETC1S/UASTC choice is quality policy, never client compatibility.Encoding shells out to KTX-Software's
toktx(the ffmpeg pattern; no pure-Python Basis encoder exists) via the newimg_utils/ktx2_encoder.py: discovery walks PATH, the conventional install dirs, then the AppInstaller catalog, andImgUtils.register_ktx2_encoderis the substitution seam, mirroringregister_dds_codec— with the difference that the built-in wrapper self-resolves whenever the binary exists.save_imageroutes.ktx2before every PIL concern (Basis is 8-bit LDR; the mode fixup happens against the staged PNG, with its own_CONTAINER_MODE_FALLBACKSrows measured at the stage). A float source is refused by name instead of getting a table row: the rows that exist reduce precision inside a bounded range, but PIL'sF->Lclips to 0-255, which flattens the 0..1 data an EXR normally carries to black and white — so an EXR into.ktx2now raises aValueErrornaming the 8-bit-LDR constraint rather than dying three frames deeper onOSError: cannot write mode F as PNG. The newDELIVERY_FORMATStier deliberately stays out ofwritable: panels persist format combos by index, so growing that tuple would re-point every saved selection — a UI offers KTX2 explicitly, gated onktx2_available().The per-map codec is derived, not configured:
MapOptimizer.resolve_compressiongives ETC1S to exactly the mapsMapRegistry.is_lossy_safeadmits to lossy containers (unpacked sRGB — base color, emissive) and UASTC to everything else — normals, packed masks, linear data, unknowns — so the codec choice and the lossy gate cannot disagree. An explicit ETC1S on a data map is upgraded to UASTC aloud, mirroringresolve_quality's refusal semantics; the quality dial passes through only where ETC1S lands. The colorspace label rides the same resolution because the KTX2 DFD records the transfer function and a wrong label shifts every texel.assessreports the codec (predicted["compression"]) and surfaces refusals through the same helper — dry-run parity by construction.optimize_glb_textures(image_format="KTX2")is the packaged half: codec by glTF slot (normal / metallic-roughness / occlusion → UASTC+linear; base color / emissive → ETC1S+sRGB; an image shared across slots takes the stricter treatment), textures rebind throughKHR_texture_basisuwith no fallback kept — the extension goes inextensionsRequired— dimensions snap down to POT (the KHR extension wants multiple-of-4 and full mip pyramids, generated at encode time), the keep-original-when-larger rule is deliberately off (the win being bought is GPU residency, not bytes), and lightmaps stay on the lossless-WebP path (ETC1S would blotch a bake for the same reason lossy WebP did). A missing encoder raises upfront rather than silently shipping WebP.PreviewDeliverer(texture_format="KTX2")plumbs it into the WebXR push, andpreview_viewer.htmlwires three.jsKTX2Loaderwith the transcoder off the same unpkg CDN the page already rides — inert for WebP deliverables, required for KTX2 ones.~35 new tests: the toktx argv contract per codec/label/quality, staged-mode table measurement, the derivation/refusal matrix, real-run vs. dry-run parity, GLB bindings/POT snap/lightmap carve-out/fail-before-touching, plus binary-gated real-encode integration tests that skip where toktx is absent. Full suite: 3077 tests, 0 failures.
-
2026-08-13 —
Polylinegained arc-length sampling, extracted from the private closureframeswas hiding it in (point_at_arc,cumulative_lengths).point_atis index space and says so —t=0.5is the middle by index, not by length — which is only the same thing when every segment is equal.framesalready needed the arc-length answer and carried its own inline cumulative-length walk plus a linear scan to find the containing span. That sampler is now a public primitive:point_at_arc(points, t)matches the(points, t) -> pointshaperesample's existinginterpolationparameter was designed to accept, so an arc-uniform distribution isresample(pts, n, interpolation=Polyline.point_at_arc)with no new API shape.framesdrops its duplicate (its linear span scan also became abisect), but shares the math through a private_point_at_cum(pts, cum, s)rather than calling the one-shotpoint_at_arcitself — it takes three samples per frame (the position plus two tangent probes), so a one-shot entry point would rebuild the whole cumulative-length table on every one of them, turning an O(n) setup into O(segments x n). Measured on a 200-point rail: 127ms at 200 segments, 604 table builds. Sharing the table brings that to 2.5ms and exactly one build, which a test now pins by counting the builds rather than by wall clock.Every measurement in the class now goes through one
_segment_length, which reads points by index rather than zipping them, so DCC point types work alongside plain sequences —om.MPointcarries a fourthwcomponent a naivezipwould fold into the distance. That consolidation was the point: adding a second distance walk besidelength's existing one would have left the class acceptingMPointin some methods and silently mis-measuring it in others, which is a worse trap than either behaviour on its own.lengthnow delegates tocumulative_lengths. Degenerate inputs are pinned by test (empty, single point, all-coincident) since a zero-length polyline has no arc to walk, as is the w-component tolerance (via a 4-tuple, so the test stays DCC-free).mayatk's tube rig is the first consumer: it places spline drivers at 20%/80% of arc, which its chord-based math had been approximating.