|
|
// 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();
|
|
|
}
|
|
|
})();
|