You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1171 lines
46 KiB
JavaScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Decocraft UV Packer — Blockbench plugin
//
// Install: Blockbench → File → Plugins → "Load Plugin from File" → pick this file.
// Use: Tools → "Pack UVs (Decocraft)".
// Tools → "Suggest Shares (ML)" — needs share_model.json (load once).
//
// Behavior:
// - Operates on selected cubes (or all cubes if nothing selected).
// - Operates on per-face UV cubes. In mixed-mode projects (project box-UV
// but some cubes overridden to per-face), only the per-face cubes are
// touched; box-UV cubes are skipped with a count in the success toast.
// - Detects share-groups: faces whose current UV rect is identical (modulo flips
// and per-texture). All faces in a share-group end up at one packed slot;
// pixels are blitted once. Each face keeps its own flip orientation.
// - Sort order is biggest outliner-group first, then biggest share-group within.
// This naturally clusters same-group items in skyline output without forcing
// hard sub-bins (which would break models that share textures across groups).
// - Skyline bottom-left packer, zero padding, into the current canvas.
// - Single Undo entry covers UVs + texture pixels.
// - Suggest Shares: LightGBM model trained on 211k face-pair examples; surfaces
// candidate share-merges the heuristic missed. Held-out AUC 0.985.
(function () {
let actionPack, actionLoadModel;
// Forced share-groups accepted from the suggest dialog within the SAME
// pack run; consumed once by planPacking. Cleared at the start of each run.
let forcedShares = []; // [{ texKey, faceIds: [<cube.uuid.face>...] }]
Plugin.register('decocraft_uv_packer', {
title: 'Decocraft UV Packer',
author: 'Decocraft',
description: 'One-button: ML-suggest share merges, then sort + repack + crop.',
icon: 'view_quilt',
version: '0.5.0',
variant: 'both',
onload() {
actionPack = new Action('pack_uvs_decocraft', {
name: 'Pack UVs (Decocraft)',
description: 'Suggest ML share-merges (if model loaded), then pack + crop texture.',
icon: 'view_quilt',
category: 'edit',
click: runOneShot,
});
actionLoadModel = new Action('load_share_model_decocraft', {
name: 'Load Share Model JSON',
description: 'One-time setup: pick share_model.json (+ optional inference_test_cases.json).',
icon: 'upload_file',
category: 'edit',
click: pickShareModel,
});
MenuBar.addAction(actionPack, 'tools');
MenuBar.addAction(actionLoadModel, 'tools');
},
onunload() {
if (actionPack) actionPack.delete();
if (actionLoadModel) actionLoadModel.delete();
},
});
// -----------------------------------------------------------------------
// One-button entry: optional ML suggest dialog → pack + crop
// -----------------------------------------------------------------------
function runOneShot() {
if (!Project) return Blockbench.showQuickMessage('No project open');
forcedShares = []; // fresh queue per run
// If no model is loaded, skip dialog and just pack.
if (!shareModel) {
runPacker();
return;
}
// Score candidates. If none above threshold, also skip the dialog.
const scoring = scoreCandidates();
if (!scoring || scoring.top.length === 0) {
runPacker();
return;
}
showSuggestDialog(scoring, {
onAcceptAndPack(acceptedRows) {
for (const c of acceptedRows) {
const a = scoring.faces[c.i], b = scoring.faces[c.j];
const ga = scoring.heuristicGroupOf(c.i);
const gb = scoring.heuristicGroupOf(c.j);
const faceIds = [...new Set([
...ga.map((k) => scoring.faces[k].faceId),
...gb.map((k) => scoring.faces[k].faceId),
])];
forcedShares.push({ texKey: a.texKey, faceIds });
}
runPacker();
},
onPackOnly() {
runPacker();
},
// Cancel = no-op
});
}
// ---------------------------------------------------------------------
// Skyline bottom-left packer (operates in UV float space)
// ---------------------------------------------------------------------
const EPS = 1e-4;
class Skyline {
constructor(w, h) {
this.w = w;
this.h = h;
this.nodes = [{ x: 0, y: 0, w }];
}
insert(rw, rh) {
let bestY = Infinity, bestX = Infinity, bestIdx = -1;
for (let i = 0; i < this.nodes.length; i++) {
const fit = this._fit(i, rw, rh);
if (fit === null) continue;
if (fit.y < bestY - EPS || (Math.abs(fit.y - bestY) < EPS && fit.x < bestX)) {
bestY = fit.y;
bestX = fit.x;
bestIdx = i;
}
}
if (bestIdx < 0) return null;
this._addLevel(bestIdx, bestX, bestY, rw, rh);
return { x: bestX, y: bestY };
}
_fit(idx, rw, rh) {
const x = this.nodes[idx].x;
if (x + rw > this.w + EPS) return null;
let widthLeft = rw, i = idx, y = this.nodes[idx].y;
while (widthLeft > EPS) {
if (i >= this.nodes.length) return null;
if (this.nodes[i].y > y) y = this.nodes[i].y;
if (y + rh > this.h + EPS) return null;
widthLeft -= this.nodes[i].w;
i++;
}
return { x, y };
}
_addLevel(idx, x, y, rw, rh) {
const top = { x, y: y + rh, w: rw };
this.nodes.splice(idx, 0, top);
for (let i = idx + 1; i < this.nodes.length;) {
const n = this.nodes[i];
if (n.x < top.x + top.w - EPS) {
const shrink = top.x + top.w - n.x;
if (shrink < n.w - EPS) {
n.x += shrink;
n.w -= shrink;
break;
}
this.nodes.splice(i, 1);
} else break;
}
for (let i = 0; i < this.nodes.length - 1;) {
if (Math.abs(this.nodes[i].y - this.nodes[i + 1].y) < EPS) {
this.nodes[i].w += this.nodes[i + 1].w;
this.nodes.splice(i + 1, 1);
} else i++;
}
}
usedHeight() {
let h = 0;
for (const n of this.nodes) if (n.y > h) h = n.y;
return h;
}
}
// ---------------------------------------------------------------------
// Main entry
// ---------------------------------------------------------------------
const FACE_NAMES = ['north', 'east', 'south', 'west', 'up', 'down'];
function runPacker() {
if (!Project) return Blockbench.showQuickMessage('No project open');
const allTextures = Texture.all || [];
if (!allTextures.length) return Blockbench.showQuickMessage('No textures');
const uvW = Project.texture_width;
const uvH = Project.texture_height;
// Always pack ALL textured cubes, regardless of selection. Packing a
// subset would rebuild the texture without the unselected cubes' pixels,
// putting their UVs out of bounds and erasing their content. Selection
// only controls which cubes get reoriented in the Reorient flow — the
// packer itself touches everything that uses the project's textures.
const cubes = Cube.all.slice();
if (!cubes.length) return Blockbench.showQuickMessage('No cubes to pack');
// Outliner-group identity (for clustering preference). Use parent uuid.
const outlinerGroupOf = (cube) =>
cube.parent && cube.parent.uuid ? cube.parent.uuid : '__root__';
// Stable outliner index per cube — Cube.all is in outliner order.
const cubeOrder = new Map();
Cube.all.forEach((c, i) => cubeOrder.set(c.uuid, i));
// Per-texture face collection. Skip cubes set to box-UV mode (per-cube
// setting; project-level box_uv is irrelevant — a project in box-UV mode
// can still have individual cubes configured for per-face UVs).
let skippedBoxCubes = 0;
const facesByTexture = new Map(); // texId -> face[]
for (const cube of cubes) {
if (!cube.faces) continue;
if (cube.box_uv === true) { skippedBoxCubes++; continue; }
const ogId = outlinerGroupOf(cube);
for (const fname of FACE_NAMES) {
const face = cube.faces[fname];
if (!face || !Array.isArray(face.uv) || face.uv.length !== 4) continue;
const texId = resolveTextureId(face);
if (texId === null) continue;
const [x1, y1, x2, y2] = face.uv;
const w = Math.abs(x2 - x1);
const h = Math.abs(y2 - y1);
if (w <= EPS || h <= EPS) continue;
const minX = Math.min(x1, x2);
const minY = Math.min(y1, y2);
if (!facesByTexture.has(texId)) facesByTexture.set(texId, []);
facesByTexture.get(texId).push({
cube,
fname,
face,
uv: [x1, y1, x2, y2],
minX, minY, w, h,
flipX: x2 < x1,
flipY: y2 < y1,
rotation: typeof face.rotation === 'number' ? (face.rotation % 360 + 360) % 360 : 0,
ogId,
cubeIdx: cubeOrder.get(cube.uuid) ?? 0,
// Filled by planPacking when the face joins a share-group with a
// different canonical pixel rect. XORed with flipX/flipY in apply.
mergeFlipX: false,
mergeFlipY: false,
});
}
}
if (!facesByTexture.size) return Blockbench.showQuickMessage('No textured faces to pack');
// Plan all per-texture packings. If any face won't fit at the current
// working canvas size, expand the working canvas (doubling whichever
// dim is smaller) and retry. The final texture is cropped to pow2 of
// the actual packed bbox afterwards, so expanding here doesn't leave
// dead space — it just gives the skyline room to seat large rects.
const MAX_CANVAS = 8192;
let workW = uvW, workH = uvH;
let plans = null;
let texMissing = null;
while (true) {
plans = [];
let failure = null;
for (const [texId, faces] of facesByTexture.entries()) {
const tex = Texture.all.find((t) => t.uuid === texId) || Texture.all.find((t) => String(t.id) === String(texId));
if (!tex || !tex.img || !tex.img.complete || !tex.img.naturalWidth) {
texMissing = tex?.name ?? texId;
plans = null;
break;
}
const forced = forcedShares.filter((fs) => fs.texKey === texId);
const plan = planPacking(faces, tex, workW, workH, forced);
if (plan.error) { failure = plan.error; break; }
plans.push({ tex, ...plan });
}
if (texMissing) {
return Blockbench.showQuickMessage(`Texture not loaded: ${texMissing}`);
}
if (!failure) break;
// Expand the working canvas. Double whichever dim is smaller so the
// canvas grows in both directions over multiple expansions.
if (workW <= workH) workW = Math.min(MAX_CANVAS, workW * 2);
else workH = Math.min(MAX_CANVAS, workH * 2);
if (workW >= MAX_CANVAS && workH >= MAX_CANVAS) {
return Blockbench.showMessageBox({
title: 'Pack failed',
message: `Cannot fit at ${workW}×${workH}. Last error:\n\n${failure}`,
});
}
}
// Compute global packed bbox in UV space (across ALL textures)
let bboxW = 0, bboxH = 0;
for (const plan of plans) {
for (const p of plan.placements) {
const right = p.x + p.sg.w;
const bottom = p.y + p.sg.h;
if (right > bboxW) bboxW = right;
if (bottom > bboxH) bboxH = bottom;
}
}
// Round up to next power-of-2; minimum 1.
const newUVW = nextPow2(Math.max(1, Math.ceil(bboxW)));
const newUVH = nextPow2(Math.max(1, Math.ceil(bboxH)));
const sizeChanged = newUVW !== uvW || newUVH !== uvH;
// Identify variant textures: any texture in the project that no face is
// pointing to. They share the project's UV space, so they get the same
// pixel-rearrangement as the largest faced-texture plan. This handles
// multi-skin models (default + _alt_1 + _alt_2 etc).
const facedTexIds = new Set(plans.map((p) => p.tex.uuid));
const variants = Texture.all.filter((t) => !facedTexIds.has(t.uuid)
&& t.img && t.img.complete && t.img.naturalWidth);
let driverPlan = null;
if (variants.length && plans.length) {
driverPlan = plans[0];
for (const p of plans) if (p.faceCount > driverPlan.faceCount) driverPlan = p;
}
// Apply
Undo.initEdit({
elements: cubes,
textures: [...plans.map((p) => p.tex), ...variants],
uv_mode: true,
});
let totalFaces = 0, totalShareGroups = 0;
for (const plan of plans) {
applyPlan(plan, uvW, uvH, newUVW, newUVH);
totalFaces += plan.faceCount;
totalShareGroups += plan.shareGroups.length;
}
if (driverPlan) {
for (const v of variants) {
rearrangeVariant(v, driverPlan, uvW, uvH, newUVW, newUVH);
}
}
if (sizeChanged) {
resizeProject(newUVW, newUVH);
}
Canvas.updateAllUVs();
if (typeof Canvas.updateAllFaces === 'function') Canvas.updateAllFaces();
if (typeof updateSelection === 'function') updateSelection();
Undo.finishEdit('Pack UVs');
const sizeMsg = sizeChanged
? ` · canvas ${uvW}×${uvH}${newUVW}×${newUVH}`
: '';
const skipMsg = skippedBoxCubes > 0
? ` · skipped ${skippedBoxCubes} box-UV cube${skippedBoxCubes > 1 ? 's' : ''}`
: '';
const variantMsg = variants.length
? ` · rearranged ${variants.length} variant${variants.length > 1 ? 's' : ''}`
: '';
Blockbench.showQuickMessage(
`Packed ${totalFaces} faces into ${totalShareGroups} slots${sizeMsg}${skipMsg}${variantMsg}`
);
}
// Rearrange a variant texture's pixels using the same placements as the
// driver plan. Faces aren't pointing at this texture, so no UVs are written;
// only its pixel canvas is rebuilt and (if needed) resized.
function rearrangeVariant(variantTex, driverPlan, oldUVW, oldUVH, newUVW, newUVH) {
const oldPxW = variantTex.img.naturalWidth;
const oldPxH = variantTex.img.naturalHeight;
const sx = oldPxW / oldUVW;
const sy = oldPxH / oldUVH;
const newPxW = Math.max(1, Math.round(newUVW * sx));
const newPxH = Math.max(1, Math.round(newUVH * sy));
const canvas = document.createElement('canvas');
canvas.width = newPxW;
canvas.height = newPxH;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, newPxW, newPxH);
for (const p of driverPlan.placements) {
const { sg, x, y } = p;
const srcX = sg.srcMinX * sx;
const srcY = sg.srcMinY * sy;
const wPx = sg.w * sx;
const hPx = sg.h * sy;
const dstX = x * sx;
const dstY = y * sy;
try {
ctx.drawImage(variantTex.img, srcX, srcY, wPx, hPx, dstX, dstY, wPx, hPx);
} catch (e) { /* drop this rect on tainted canvas */ }
}
variantTex.updateSource(canvas.toDataURL('image/png'));
if (typeof variantTex.width === 'number') variantTex.width = newPxW;
if (typeof variantTex.height === 'number') variantTex.height = newPxH;
}
function nextPow2(n) {
n = Math.max(1, n | 0);
if ((n & (n - 1)) === 0) return n;
return 1 << (32 - Math.clz32(n));
}
function resizeProject(newUVW, newUVH) {
// Project-level UV space.
Project.texture_width = newUVW;
Project.texture_height = newUVH;
// Per-texture UV-space override (if set independently).
for (const tex of Texture.all) {
if (typeof tex.uv_width === 'number') tex.uv_width = newUVW;
if (typeof tex.uv_height === 'number') tex.uv_height = newUVH;
}
if (typeof Canvas.updateLayeredTextures === 'function') Canvas.updateLayeredTextures();
}
function resolveTextureId(face) {
const t = face.texture;
if (t === null || t === undefined || t === false) return null;
if (typeof t === 'string') return t; // uuid
if (typeof t === 'number') return String(t); // legacy id
if (t && t.uuid) return t.uuid; // Texture instance
return null;
}
function planPacking(faces, tex, uvW, uvH, forcedShareList) {
// Union-find groups faces that should share a slot. Edges come from:
// (a) identical normalized UV rect within this texture (heuristic)
// (b) accepted ML suggestions in forcedShareList
// Forced merges across different dimensions are rejected (would distort).
const parent = new Int32Array(faces.length);
for (let i = 0; i < faces.length; i++) parent[i] = i;
function find(i) {
while (parent[i] !== i) { parent[i] = parent[parent[i]]; i = parent[i]; }
return i;
}
function union(a, b) {
const ra = find(a), rb = find(b);
if (ra !== rb) parent[ra] = rb;
}
const idIdx = new Map();
for (let i = 0; i < faces.length; i++) {
idIdx.set(`${faces[i].cube.uuid}.${faces[i].fname}`, i);
}
// (a) identical-rect edges. Include face.rotation in the key so faces
// with the same source rect but different rotations don't merge.
const rectFirst = new Map();
for (let i = 0; i < faces.length; i++) {
const f = faces[i];
const key = `${f.minX.toFixed(4)},${f.minY.toFixed(4)},${f.w.toFixed(4)},${f.h.toFixed(4)}|${f.rotation}`;
if (rectFirst.has(key)) union(rectFirst.get(key), i);
else rectFirst.set(key, i);
}
// (b) forced shares from ML suggestions
let rejectedForced = 0;
for (const fs of forcedShareList || []) {
const ids = fs.faceIds.map((id) => idIdx.get(id)).filter((i) => i !== undefined);
if (ids.length < 2) continue;
// Reject if dimensions differ between members
const ref = faces[ids[0]];
const dimsMatch = ids.every((i) =>
Math.abs(faces[i].w - ref.w) < EPS && Math.abs(faces[i].h - ref.h) < EPS
);
if (!dimsMatch) { rejectedForced++; continue; }
for (let k = 1; k < ids.length; k++) union(ids[0], ids[k]);
}
// Materialize share-groups from union-find roots
const groupsByRoot = new Map();
for (let i = 0; i < faces.length; i++) {
const r = find(i);
if (!groupsByRoot.has(r)) groupsByRoot.set(r, []);
groupsByRoot.get(r).push(i);
}
const shareGroups = [];
for (const indices of groupsByRoot.values()) {
// Canonical face = largest area; donates source pixels.
let canon = faces[indices[0]];
for (const i of indices) {
const f = faces[i];
if (f.w * f.h > canon.w * canon.h) canon = f;
}
const sg = {
key: `${canon.minX.toFixed(4)},${canon.minY.toFixed(4)},${canon.w.toFixed(4)},${canon.h.toFixed(4)}`,
w: canon.w,
h: canon.h,
srcMinX: canon.minX,
srcMinY: canon.minY,
faces: indices.map((i) => faces[i]),
ogCounts: new Map(),
minCubeIdx: canon.cubeIdx,
};
for (const f of sg.faces) {
sg.ogCounts.set(f.ogId, (sg.ogCounts.get(f.ogId) || 0) + 1);
if (f.cubeIdx < sg.minCubeIdx) sg.minCubeIdx = f.cubeIdx;
}
let bestId = '__root__', bestCount = -1;
for (const [id, c] of sg.ogCounts) {
if (c > bestCount) { bestCount = c; bestId = id; }
}
sg.primaryOg = bestId;
sg.area = sg.w * sg.h;
shareGroups.push(sg);
}
if (rejectedForced > 0) {
console.warn(`[uv-packer] Rejected ${rejectedForced} forced merges (dimension mismatch)`);
}
// Per share-group: when members use different source rects (forced ML
// merges, or future cross-rect heuristic merges), figure out which
// orientation of each member's pixels best matches the canonical, and
// store it as mergeFlipX/mergeFlipY for apply to XOR with the original
// flipX/flipY. Without this, two faces that the artist drew as
// mirror-painted regions lose their mirror after merging.
detectMergeOrientations(shareGroups, tex, uvW, uvH);
// Outliner-group totals (for ordering between groups)
const ogTotal = new Map();
const ogFirstIdx = new Map();
for (const sg of shareGroups) {
ogTotal.set(sg.primaryOg, (ogTotal.get(sg.primaryOg) || 0) + sg.area);
const cur = ogFirstIdx.get(sg.primaryOg);
if (cur === undefined || sg.minCubeIdx < cur) ogFirstIdx.set(sg.primaryOg, sg.minCubeIdx);
}
// Sort: outliner-group total area DESC, then outliner-group first appearance,
// then share-group area DESC, then stable key.
shareGroups.sort((a, b) => {
const aT = ogTotal.get(a.primaryOg);
const bT = ogTotal.get(b.primaryOg);
if (bT !== aT) return bT - aT;
if (a.primaryOg !== b.primaryOg) {
return (ogFirstIdx.get(a.primaryOg) ?? 0) - (ogFirstIdx.get(b.primaryOg) ?? 0);
}
if (b.area !== a.area) return b.area - a.area;
const am = Math.max(a.w, a.h), bm = Math.max(b.w, b.h);
if (bm !== am) return bm - am;
return a.key.localeCompare(b.key);
});
// Pack
const skyline = new Skyline(uvW, uvH);
const placements = [];
for (const sg of shareGroups) {
const slot = skyline.insert(sg.w, sg.h);
if (!slot) {
const usedFor = sg.faces[0];
return {
error: `Couldn't fit slot ${sg.w}×${sg.h} (used by ${usedFor.cube.name}.${usedFor.fname}` +
(sg.faces.length > 1 ? ` and ${sg.faces.length - 1} more` : '') +
`) into ${tex.name} canvas ${uvW}×${uvH}.\n\nIncrease texture resolution or unselect cubes.`,
};
}
placements.push({ sg, x: slot.x, y: slot.y });
}
return {
shareGroups,
placements,
faceCount: faces.length,
usedHeight: skyline.usedHeight(),
};
}
function detectMergeOrientations(shareGroups, tex, uvW, uvH) {
if (!tex || !tex.img || !tex.img.complete || !tex.img.naturalWidth) return;
const sx = tex.img.naturalWidth / uvW;
const sy = tex.img.naturalHeight / uvH;
// Cache pixel reads so we don't re-getImageData the canonical for every member.
const pixelCache = new Map();
function pixelsAt(minX, minY, w, h) {
const key = `${minX.toFixed(4)},${minY.toFixed(4)},${w.toFixed(4)},${h.toFixed(4)}`;
if (pixelCache.has(key)) return pixelCache.get(key);
const px = Math.floor(minX * sx);
const py = Math.floor(minY * sy);
const pw = Math.max(1, Math.round(w * sx));
const ph = Math.max(1, Math.round(h * sy));
const c = document.createElement('canvas');
c.width = pw; c.height = ph;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = false;
try {
ctx.drawImage(tex.img, px, py, pw, ph, 0, 0, pw, ph);
} catch (e) {
return null;
}
let data;
try { data = ctx.getImageData(0, 0, pw, ph); } catch (e) { return null; }
pixelCache.set(key, data);
return data;
}
function pixelDiff(a, b, w, h, fx, fy) {
const ad = a.data, bd = b.data;
let diff = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const ai = (y * w + x) * 4;
const bxX = fx ? (w - 1 - x) : x;
const bxY = fy ? (h - 1 - y) : y;
const bi = (bxY * w + bxX) * 4;
// Sum absolute RGBA differences.
diff += Math.abs(ad[ai] - bd[bi])
+ Math.abs(ad[ai + 1] - bd[bi + 1])
+ Math.abs(ad[ai + 2] - bd[bi + 2])
+ Math.abs(ad[ai + 3] - bd[bi + 3]);
}
}
return diff;
}
for (const sg of shareGroups) {
if (sg.faces.length < 2) continue;
// If all members share the canonical's source rect, no orientation work is needed.
const allSame = sg.faces.every((f) =>
Math.abs(f.minX - sg.srcMinX) < EPS &&
Math.abs(f.minY - sg.srcMinY) < EPS &&
Math.abs(f.w - sg.w) < EPS &&
Math.abs(f.h - sg.h) < EPS
);
if (allSame) {
for (const f of sg.faces) { f.mergeFlipX = false; f.mergeFlipY = false; }
continue;
}
const canonPixels = pixelsAt(sg.srcMinX, sg.srcMinY, sg.w, sg.h);
if (!canonPixels) continue; // can't read; leave defaults
for (const face of sg.faces) {
const sameRect =
Math.abs(face.minX - sg.srcMinX) < EPS &&
Math.abs(face.minY - sg.srcMinY) < EPS &&
Math.abs(face.w - sg.w) < EPS &&
Math.abs(face.h - sg.h) < EPS;
if (sameRect) { face.mergeFlipX = false; face.mergeFlipY = false; continue; }
const otherPixels = pixelsAt(face.minX, face.minY, face.w, face.h);
if (!otherPixels) { face.mergeFlipX = false; face.mergeFlipY = false; continue; }
if (otherPixels.width !== canonPixels.width || otherPixels.height !== canonPixels.height) {
face.mergeFlipX = false; face.mergeFlipY = false; continue;
}
let bestDiff = Infinity, bestFX = false, bestFY = false;
for (const fx of [false, true]) {
for (const fy of [false, true]) {
const d = pixelDiff(canonPixels, otherPixels, canonPixels.width, canonPixels.height, fx, fy);
if (d < bestDiff) { bestDiff = d; bestFX = fx; bestFY = fy; }
}
}
face.mergeFlipX = bestFX;
face.mergeFlipY = bestFY;
}
}
}
function applyPlan(plan, oldUVW, oldUVH, newUVW, newUVH) {
const { tex, placements } = plan;
const oldPxW = tex.img.naturalWidth;
const oldPxH = tex.img.naturalHeight;
// Preserve per-texture UV→pixel scale so per-face resolution is unchanged.
const sx = oldPxW / oldUVW;
const sy = oldPxH / oldUVH;
const newPxW = Math.max(1, Math.round(newUVW * sx));
const newPxH = Math.max(1, Math.round(newUVH * sy));
const canvas = document.createElement('canvas');
canvas.width = newPxW;
canvas.height = newPxH;
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.clearRect(0, 0, newPxW, newPxH);
for (const p of placements) {
const { sg, x, y } = p;
const srcX = sg.srcMinX * sx;
const srcY = sg.srcMinY * sy;
const wPx = sg.w * sx;
const hPx = sg.h * sy;
const dstX = x * sx;
const dstY = y * sy;
ctx.drawImage(tex.img, srcX, srcY, wPx, hPx, dstX, dstY, wPx, hPx);
for (const face of sg.faces) {
// Final flip = original UV flip XOR mergeFlip (set when this face's
// pixel content needs to be mirrored to look right against canonical
// pixels — see detectMergeOrientations).
const fx = (!!face.flipX) !== (!!face.mergeFlipX);
const fy = (!!face.flipY) !== (!!face.mergeFlipY);
const x1 = fx ? x + sg.w : x;
const x2 = fx ? x : x + sg.w;
const y1 = fy ? y + sg.h : y;
const y2 = fy ? y : y + sg.h;
face.face.uv = [x1, y1, x2, y2];
}
}
// Always replace via updateSource. tex.edit was crashing when the
// texture's internal canvas was in a transient state (post-updateSource
// from an earlier step in the chain), and we don't need its undo-tracking
// benefit because the outer Undo.initEdit/finishEdit covers this run.
tex.updateSource(canvas.toDataURL('image/png'));
if (typeof tex.width === 'number') tex.width = newPxW;
if (typeof tex.height === 'number') tex.height = newPxH;
}
// =====================================================================
// ML: share-prediction classifier
// =====================================================================
const MODEL_STORAGE_KEY = 'decocraft_uv_packer.share_model_v1';
const TESTS_STORAGE_KEY = 'decocraft_uv_packer.share_model_tests_v1';
let shareModel = null; // { feature_names, categorical_features, trees, featureIndex }
const DIR_IDX_ML = { north: 0, east: 1, south: 2, west: 3, up: 4, down: 5 };
const OPP_DIR = { north: 'south', south: 'north', east: 'west', west: 'east', up: 'down', down: 'up' };
const AXIS = { north: 'z', south: 'z', east: 'x', west: 'x', up: 'y', down: 'y' };
const AXIS_IDX = { x: 0, y: 1, z: 2 };
// Mirrors extract_pairs.mjs::pairFeatures EXACTLY.
function computePairFeatures(a, b) {
const aspectA = a.uvW / a.uvH;
const aspectB = b.uvW / b.uvH;
const areaA = a.uvW * a.uvH;
const areaB = b.uvW * b.uvH;
const dx = a.cx - b.cx, dy = a.cy - b.cy, dz = a.cz - b.cz;
return {
a_dir: DIR_IDX_ML[a.dir],
b_dir: DIR_IDX_ML[b.dir],
a_uvW: a.uvW, a_uvH: a.uvH,
b_uvW: b.uvW, b_uvH: b.uvH,
abs_w_diff: Math.abs(a.uvW - b.uvW),
abs_h_diff: Math.abs(a.uvH - b.uvH),
swap_w_diff: Math.abs(a.uvW - b.uvH),
swap_h_diff: Math.abs(a.uvH - b.uvW),
area_min: Math.min(areaA, areaB),
area_max: Math.max(areaA, areaB),
area_ratio: Math.min(areaA, areaB) / Math.max(areaA, areaB),
aspect_diff: Math.abs(aspectA - aspectB),
a_cubeW: a.cubeW, a_cubeH: a.cubeH, a_cubeD: a.cubeD,
b_cubeW: b.cubeW, b_cubeH: b.cubeH, b_cubeD: b.cubeD,
cube_dim_match: (a.cubeW === b.cubeW && a.cubeH === b.cubeH && a.cubeD === b.cubeD) ? 1 : 0,
cube_w_diff: Math.abs(a.cubeW - b.cubeW),
cube_h_diff: Math.abs(a.cubeH - b.cubeH),
cube_d_diff: Math.abs(a.cubeD - b.cubeD),
same_cube: a.cubeUuid === b.cubeUuid ? 1 : 0,
same_parent: a.parentId === b.parentId ? 1 : 0,
direction_match: a.dir === b.dir ? 1 : 0,
direction_opposite: OPP_DIR[a.dir] === b.dir ? 1 : 0,
same_axis: AXIS[a.dir] === AXIS[b.dir] ? 1 : 0,
a_axis: AXIS_IDX[AXIS[a.dir]],
b_axis: AXIS_IDX[AXIS[b.dir]],
flip_match: (a.flipX === b.flipX && a.flipY === b.flipY) ? 1 : 0,
has_rot_either: (a.hasRot || b.hasRot) ? 1 : 0,
rot_match: (a.rotX === b.rotX && a.rotY === b.rotY && a.rotZ === b.rotZ) ? 1 : 0,
cube_dist: Math.sqrt(dx * dx + dy * dy + dz * dz),
same_texture: a.texKey === b.texKey ? 1 : 0,
depth_diff: Math.abs(a.depth - b.depth),
};
}
// LightGBM tree evaluator. Categorical splits are decision_type "==" with
// threshold = "cat0||cat1||..." string of category indices.
function evalTree(node, x) {
while (!('v' in node)) {
const val = x[node.f];
let goLeft;
if (val === undefined || val === null || (typeof val === 'number' && isNaN(val))) {
goLeft = node.default_left;
} else if (node.d === '==' || node.d === '==') {
// categorical
const cats = String(node.t).split('||').map(Number);
goLeft = cats.includes(val);
} else {
// "<=" numerical
goLeft = val <= node.t;
}
node = goLeft ? node.l : node.r;
}
return node.v;
}
function predictShareProb(features) {
if (!shareModel) return null;
const x = new Array(shareModel.feature_names.length);
for (let i = 0; i < shareModel.feature_names.length; i++) {
x[i] = features[shareModel.feature_names[i]];
}
let raw = 0;
for (const tree of shareModel.trees) raw += evalTree(tree.root, x);
return 1 / (1 + Math.exp(-raw));
}
function selfTestModel(testCases) {
if (!testCases || !testCases.length) return { ok: true, msg: 'no test cases' };
let maxErr = 0;
for (const tc of testCases) {
const got = predictShareProb(tc.features);
const err = Math.abs(got - tc.expected_prob);
if (err > maxErr) maxErr = err;
}
if (maxErr > 0.005) {
return { ok: false, msg: `Self-test FAIL: max error ${maxErr.toExponential(3)} across ${testCases.length} cases. JS evaluator disagrees with LightGBM.` };
}
return { ok: true, msg: `Self-test pass: max error ${maxErr.toExponential(3)} across ${testCases.length} cases.` };
}
function loadModelFromString(modelJson, testsJson) {
shareModel = JSON.parse(modelJson);
const tests = testsJson ? JSON.parse(testsJson) : [];
const result = selfTestModel(tests);
if (!result.ok) {
shareModel = null;
throw new Error(result.msg);
}
return result.msg;
}
function tryLoadFromStorage() {
try {
const m = localStorage.getItem(MODEL_STORAGE_KEY);
const t = localStorage.getItem(TESTS_STORAGE_KEY);
if (m) {
const msg = loadModelFromString(m, t);
console.log(`[uv-packer] ML model restored from cache. ${msg}`);
return true;
}
} catch (e) {
console.warn('[uv-packer] Failed to restore cached model:', e);
}
return false;
}
tryLoadFromStorage();
function pickShareModel() {
Blockbench.import({
resource_id: 'share_model',
extensions: ['json'],
type: 'JSON',
multiple: true, // allow optional test-cases file alongside
title: 'Pick share_model.json (and optionally inference_test_cases.json)',
}, (results) => {
let modelStr = null, testsStr = null;
for (const r of results) {
if (r.path && r.path.toLowerCase().includes('test')) testsStr = r.content;
else if (modelStr === null) modelStr = r.content;
else testsStr = r.content;
}
if (!modelStr) return Blockbench.showQuickMessage('No model file selected');
try {
const msg = loadModelFromString(modelStr, testsStr);
try {
localStorage.setItem(MODEL_STORAGE_KEY, modelStr);
if (testsStr) localStorage.setItem(TESTS_STORAGE_KEY, testsStr);
} catch (e) {
console.warn('[uv-packer] Could not cache to localStorage (model may exceed quota):', e);
}
Blockbench.showQuickMessage('Model loaded — ' + msg);
} catch (e) {
Blockbench.showMessageBox({ title: 'Model load failed', message: String(e.message || e) });
}
});
}
function collectFacesForML(cubes) {
const cubeOrder = new Map();
Cube.all.forEach((c, i) => cubeOrder.set(c.uuid, i));
const faces = [];
for (const cube of cubes) {
if (!cube.faces) continue;
if (cube.box_uv === true) continue; // mixed-mode projects: skip box-UV cubes
const from = cube.from || [0, 0, 0];
const to = cube.to || [0, 0, 0];
const cubeW = Math.abs(to[0] - from[0]);
const cubeH = Math.abs(to[1] - from[1]);
const cubeD = Math.abs(to[2] - from[2]);
const cx = (from[0] + to[0]) / 2;
const cy = (from[1] + to[1]) / 2;
const cz = (from[2] + to[2]) / 2;
const rot = cube.rotation || [0, 0, 0];
const parentId = cube.parent && cube.parent.uuid ? cube.parent.uuid : '__root__';
const depth = countDepth(cube);
for (const fname of FACE_NAMES) {
const face = cube.faces[fname];
if (!face || !Array.isArray(face.uv) || face.uv.length !== 4) continue;
const texKey = resolveTextureId(face);
if (texKey === null) continue;
const [x1, y1, x2, y2] = face.uv;
const w = Math.abs(x2 - x1);
const h = Math.abs(y2 - y1);
if (w <= EPS || h <= EPS) continue;
faces.push({
cube,
cubeUuid: cube.uuid,
fname,
face,
dir: fname,
uvW: w, uvH: h,
minX: Math.min(x1, x2), minY: Math.min(y1, y2),
flipX: x2 < x1 ? 1 : 0,
flipY: y2 < y1 ? 1 : 0,
cubeW, cubeH, cubeD,
cx, cy, cz,
rotX: rot[0], rotY: rot[1], rotZ: rot[2],
hasRot: rot[0] !== 0 || rot[1] !== 0 || rot[2] !== 0 ? 1 : 0,
parentId,
depth,
texKey,
shareKey: `${texKey}|${Math.min(x1, x2).toFixed(4)},${Math.min(y1, y2).toFixed(4)},${w.toFixed(4)},${h.toFixed(4)}`,
faceId: `${cube.uuid}.${fname}`,
});
}
}
return faces;
}
function countDepth(cube) {
let d = 0, p = cube.parent;
while (p && p.parent) { d++; p = p.parent; }
return d;
}
const SCORE_THRESHOLD = 0.85;
const PIXEL_MATCH_THRESHOLD = 0.85; // best of 4 orientations must match this well
// Pixel-cache + similarity helper used to filter ML suggestions whose pixels
// are too different to merge safely (e.g. white sheet vs. green bedspread).
function makePixelMatcher() {
const cache = new Map();
function getPixels(face) {
const tex = Texture.all.find((t) => t.uuid === face.texKey)
|| Texture.all.find((t) => String(t.id) === String(face.texKey));
if (!tex || !tex.img || !tex.img.complete || !tex.img.naturalWidth) return null;
const key = `${face.texKey}|${face.minX.toFixed(4)},${face.minY.toFixed(4)},${face.uvW.toFixed(4)},${face.uvH.toFixed(4)}`;
if (cache.has(key)) return cache.get(key);
const sx = tex.img.naturalWidth / Project.texture_width;
const sy = tex.img.naturalHeight / Project.texture_height;
const px = Math.floor(face.minX * sx);
const py = Math.floor(face.minY * sy);
const pw = Math.max(1, Math.round(face.uvW * sx));
const ph = Math.max(1, Math.round(face.uvH * sy));
const c = document.createElement('canvas');
c.width = pw; c.height = ph;
const ctx = c.getContext('2d');
ctx.imageSmoothingEnabled = false;
let data = null;
try {
ctx.drawImage(tex.img, px, py, pw, ph, 0, 0, pw, ph);
data = ctx.getImageData(0, 0, pw, ph);
} catch (e) { /* fall through */ }
cache.set(key, data);
return data;
}
function matchScore(faceA, faceB) {
const a = getPixels(faceA), b = getPixels(faceB);
if (!a || !b) return null;
if (a.width !== b.width || a.height !== b.height) return 0;
const w = a.width, h = a.height;
const ad = a.data, bd = b.data;
const maxDiff = w * h * 4 * 255;
let best = 0;
for (const fx of [false, true]) {
for (const fy of [false, true]) {
let diff = 0;
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const ai = (y * w + x) * 4;
const bxX = fx ? (w - 1 - x) : x;
const bxY = fy ? (h - 1 - y) : y;
const bi = (bxY * w + bxX) * 4;
diff += Math.abs(ad[ai] - bd[bi])
+ Math.abs(ad[ai + 1] - bd[bi + 1])
+ Math.abs(ad[ai + 2] - bd[bi + 2])
+ Math.abs(ad[ai + 3] - bd[bi + 3]);
}
}
const score = 1 - diff / maxDiff;
if (score > best) best = score;
}
}
return best;
}
return { matchScore };
}
// Returns null if not enough faces or model not loaded; otherwise scoring data.
function scoreCandidates() {
if (!shareModel) return null;
const cubes = (Cube.selected && Cube.selected.length ? Cube.selected : Cube.all).slice();
const faces = collectFacesForML(cubes);
if (faces.length < 2) return null;
const heuristicGroup = new Map();
for (let i = 0; i < faces.length; i++) {
const k = faces[i].shareKey;
if (!heuristicGroup.has(k)) heuristicGroup.set(k, []);
heuristicGroup.get(k).push(i);
}
const heuristicGroupOf = (i) => heuristicGroup.get(faces[i].shareKey);
const matcher = makePixelMatcher();
const candidates = [];
let pairsConsidered = 0;
let pixelRejected = 0;
for (let i = 0; i < faces.length; i++) {
for (let j = i + 1; j < faces.length; j++) {
const a = faces[i], b = faces[j];
if (a.texKey !== b.texKey) continue;
if (Math.abs(a.uvW - b.uvW) > EPS || Math.abs(a.uvH - b.uvH) > EPS) continue;
if (a.shareKey === b.shareKey) continue;
pairsConsidered++;
const p = predictShareProb(computePairFeatures(a, b));
if (p < SCORE_THRESHOLD) continue;
// Pixel similarity gate: ML can't see actual pixels, so it'll happily
// suggest merging two same-size faces with completely different
// content. Require best-of-4-orientations RGBA match >= threshold.
const pix = matcher.matchScore(a, b);
if (pix !== null && pix < PIXEL_MATCH_THRESHOLD) {
pixelRejected++;
continue;
}
candidates.push({ i, j, p, pix: pix == null ? 1 : pix });
}
}
// Combined score: ML conf and pixel match weighted equally — ranking the
// dialog so the safest highest-ML candidates float to the top.
candidates.sort((x, y) => (y.p + y.pix) - (x.p + x.pix));
const seenPairKey = new Set();
const unique = [];
for (const c of candidates) {
const ga = heuristicGroupOf(c.i), gb = heuristicGroupOf(c.j);
const ka = ga[0], kb = gb[0];
const key = ka < kb ? `${ka}-${kb}` : `${kb}-${ka}`;
if (seenPairKey.has(key)) continue;
seenPairKey.add(key);
unique.push(c);
}
const top = unique.slice(0, Math.min(unique.length, 100));
return { faces, top, candidates, heuristicGroupOf, pairsConsidered, pixelRejected };
}
function showSuggestDialog(scoring, callbacks) {
const { faces, top, candidates, heuristicGroupOf } = scoring;
const texByKey = new Map();
for (const t of Texture.all) texByKey.set(t.uuid, t);
for (const t of Texture.all) texByKey.set(String(t.id), t);
const THUMB = 56; // px box
const MIN_SCALE = 4; // pixel-art readability floor
function makeThumb(face) {
const tex = texByKey.get(face.texKey);
if (!tex || !tex.img || !tex.img.complete) return '';
const sx = tex.img.naturalWidth / Project.texture_width;
const sy = tex.img.naturalHeight / Project.texture_height;
const srcW = Math.max(1, face.uvW * sx);
const srcH = Math.max(1, face.uvH * sy);
const srcX = face.minX * sx;
const srcY = face.minY * sy;
const fitScale = Math.min(THUMB / srcW, THUMB / srcH);
const scale = Math.max(fitScale, Math.min(MIN_SCALE, fitScale));
const dstW = Math.max(1, Math.round(srcW * scale));
const dstH = Math.max(1, Math.round(srcH * scale));
const c = document.createElement('canvas');
c.width = THUMB;
c.height = THUMB;
const cx = c.getContext('2d');
cx.imageSmoothingEnabled = false;
// Checker background so transparent pixels are visible
cx.fillStyle = '#1e1e1e';
cx.fillRect(0, 0, THUMB, THUMB);
cx.fillStyle = '#2a2a2a';
const cell = 4;
for (let yy = 0; yy < THUMB; yy += cell) {
for (let xx = ((yy / cell) % 2) * cell; xx < THUMB; xx += cell * 2) {
cx.fillRect(xx, yy, cell, cell);
}
}
const ox = Math.round((THUMB - dstW) / 2);
const oy = Math.round((THUMB - dstH) / 2);
try {
cx.drawImage(tex.img, srcX, srcY, srcW, srcH, ox, oy, dstW, dstH);
} catch (e) { /* leave checker bg */ }
return c.toDataURL();
}
// Disambiguate duplicate cube names: append #<outliner-index> when collision.
const cubeNameCount = new Map();
for (const f of faces) cubeNameCount.set(f.cube.name, (cubeNameCount.get(f.cube.name) || 0) + 1);
function labelFace(faceIdx, group) {
const f = faces[faceIdx];
const collisions = cubeNameCount.get(f.cube.name) > 1;
const cubeIdx = Cube.all.findIndex((c) => c.uuid === f.cube.uuid);
const cubeLabel = collisions ? `${f.cube.name}#${cubeIdx}` : f.cube.name;
const extra = group.length > 1 ? ` (+${group.length - 1})` : '';
return `${cubeLabel}.${f.fname}${extra}`;
}
// Build review dialog with thumbnails
const rows = top.map((c, idx) => {
const ga = heuristicGroupOf(c.i), gb = heuristicGroupOf(c.j);
const a = faces[c.i], b = faces[c.j];
const thumbA = makeThumb(a);
const thumbB = makeThumb(b);
const aName = labelFace(c.i, ga);
const bName = labelFace(c.j, gb);
const imgStyle = `width:${THUMB}px;height:${THUMB}px;image-rendering:pixelated;image-rendering:crisp-edges;border:1px solid #444;vertical-align:middle`;
const pixPct = (c.pix * 100).toFixed(1);
const pixColor = c.pix >= 0.99 ? '#7ec97e' : (c.pix >= 0.92 ? '#cfcf7e' : '#d4a05a');
return `
<tr style="border-bottom:1px solid #2a2a2a">
<td style="padding:6px 4px"><input type="checkbox" data-idx="${idx}" checked></td>
<td style="padding:6px 8px"><div style="font-weight:bold">${(c.p * 100).toFixed(1)}%</div><div style="color:${pixColor};font-size:11px">${pixPct}% px</div></td>
<td style="padding:6px 4px"><img src="${thumbA}" style="${imgStyle}"></td>
<td style="padding:6px 8px;min-width:120px">${aName}</td>
<td style="padding:6px 4px;color:#888;font-size:18px">↔</td>
<td style="padding:6px 4px"><img src="${thumbB}" style="${imgStyle}"></td>
<td style="padding:6px 8px;min-width:120px">${bName}</td>
<td style="padding:6px 8px;color:#aaa">${a.uvW}×${a.uvH}</td>
</tr>`;
}).join('');
const rejectedNote = scoring.pixelRejected
? ` · ${scoring.pixelRejected} ML candidates rejected for pixel mismatch`
: '';
const html = `
<div style="max-height:65vh;overflow-y:auto">
<p>Top ${top.length} share suggestions (${candidates.length} total above ${SCORE_THRESHOLD * 100}% ML conf and ${PIXEL_MATCH_THRESHOLD * 100}% pixel match${rejectedNote}).
Top number = ML confidence; lower = best-of-4-orientations RGBA pixel match. Uncheck pairs whose preview thumbs disagree.</p>
<table style="width:100%;border-collapse:collapse;font-family:monospace;font-size:12px">
<thead><tr style="text-align:left;border-bottom:1px solid #555;color:#aaa">
<th></th>
<th style="padding:6px 8px">Conf / Px</th>
<th style="padding:6px 4px">Preview A</th>
<th style="padding:6px 8px">Face A</th>
<th></th>
<th style="padding:6px 4px">Preview B</th>
<th style="padding:6px 8px">Face B</th>
<th style="padding:6px 8px">Dims</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
const dialog = new Dialog({
id: 'share_suggestions',
title: 'Share Suggestions',
lines: [html],
width: 760,
buttons: ['Accept & Pack', 'Pack without merging', 'Cancel'],
onButton(idx) {
// Manually close the dialog so the rest of the chain can run with
// a fresh repaint between open and pack.
dialog.hide();
if (idx === 2) return; // Cancel
if (idx === 1) {
if (callbacks.onPackOnly) setTimeout(() => callbacks.onPackOnly(), 30);
return;
}
const inputs = dialog.object.querySelectorAll('input[data-idx]');
const accepted = [];
for (const input of inputs) {
if (!input.checked) continue;
const i = parseInt(input.dataset.idx, 10);
accepted.push(top[i]);
}
if (callbacks.onAcceptAndPack) setTimeout(() => callbacks.onAcceptAndPack(accepted), 30);
},
});
dialog.show();
}
})();