// 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: [...] }] 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 # 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 `
${(c.p * 100).toFixed(1)}%
${pixPct}% px
${aName} ↔ ${bName} ${a.uvW}×${a.uvH} `; }).join(''); const rejectedNote = scoring.pixelRejected ? ` · ${scoring.pixelRejected} ML candidates rejected for pixel mismatch` : ''; const html = `

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.

${rows}
Conf / Px Preview A Face A Preview B Face B Dims
`; 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(); } })();