- 2026-09-09 --
ShotPlanner.plan_respace(..., respect_locks=True)(core_utils/engines/shots/shot_plan.py). A locked gap keeps its own width through a respace, which is the point of locking one -- but it also meant the Shots panel's Apply Gap silently skipped those gaps with nothing to say why, and no way to insist.respect_locks=Falsespends the gap on every gap alike. The lock itself is untouched either way: an override is one operation ignoring a lock, not a way to quietly clear it, so the next respace without it honours the lock again. - 2026-09-09 --
UvBudgetplans a texture budget before anything packs (geo_utils/uv_budget.py). The planning sibling ofUvPack: given per-group area, chart border length, chart count and authored density, it answers "how many maps of this size, and at what texel density?" and its inverse, in microseconds rather than the seconds a real pack costs. Demand is a closed form --area*tpu^2 + pad*perimeter*tpu + 4*pad^2*charts, all three terms in px^2 so they compare directly against a page'smap_size^2. The chart terms are what make it usable: 400 small charts and one large chart of equal area do not cost the same map, and a model pricing only area promises a density the packer cannot deliver on fragmented input. The gutter comes fromMathUtils.calculate_uv_padding, so a plan made here and a pack run later agree on what a gutter is. Per-item density is what lets one solve cover content authored at different densities: the argument then scales every group together (1.0 = exactly as authored) instead of flattening them onto one number, which matters because flattening is usually wrong -- across seven sets of a measured production assembly the range is 11.2 to 126.2 px/unit, and one shared density would cost the lowest sets 127x their UV area. Fewest-pages uses First Fit Decreasing (<= 11/9*OPT + 6/9), fixed-page-count uses Longest Processing Time (fullest page <= 4/3 - 1/(3N) times optimal); both are O(n log n) and deterministic, because the plan is shown to a person who will re-run it and expect the same answer.padding_forfloors the gutter at2**mip_levelspx and reports when it did -- the ratio rule scales the gutter with the map while mipmapping does not, so on small maps the mip floor binds.FILL_DEFAULTis calibrated, not chosen: planned page assignments were packed for real (xatlas viamayatk.UvUtils.pack_uvs, 2 pages at 1024) across varied primitives, 24 small fragments, and a lopsided set, and the worst-case achieved/predicted density ratio came out 1.158 at fill 0.50, 1.053 at 0.60, 1.009 at 0.65, 0.971 at 0.70 and 0.878 at 0.85. 0.65 is the highest value that never over-promises, so it ships -- 0.85 would have been ~12% optimistic, which on a 7-map job is a map that does not fit. A gutter-sharing parameter was built and then REMOVED, which is the more useful record: charging every chart border a full gutter strip over-counts where two charts abut, so ashareterm was added on that reasoning and swept jointly with fill (4 sharing values x 8 fill values, against real packs). Worst-case margin moved 4% across the whole sharing range against 32% across fill -- a second knob measuring nothingfilldoes not already absorb -- so it came back out. Infeasible requests report rather than returning a plausible-looking plan: an oversized item names itself, a chart count whose gutters alone exceed the budget says so,pages < 1raises, and alternates are de-duplicated against the chosen row (asking for one page more routinely re-solves to the same count).level=Trueno longer overflows a page: LPT minimizes the FULLEST page, which is not the same as keeping every page inside capacity, and the gap is reachable rather than theoretical -- 5,5,4,4,3,3,3 fits three pages of 9 exactly while LPT puts 5+3+3 on one and overflows to 11 (122% fill, reported as feasible). Levelling is a presentation preference, so a levelled layout that does not fit is discarded in favour of the FFD one that does, and the row says it stayed uneven; pinned by a test that fails on the old behaviour.BudgetItem.payloadis excluded from equality, so an item carrying a dict or a dataclass is still hashable -- a frozen type that promises to be a value should not fail onset(items)depending on what the caller stapled to it.BudgetRownow records whether itsdensityis a multiplier (scaled) and renders itself accordingly, so a scaled solve never prints as "px/unit".