diff --git a/plugins.json b/plugins.json index 971cae2e..6e48ade0 100644 --- a/plugins.json +++ b/plugins.json @@ -1,4 +1,35 @@ { + "recordshots": { + "title": "Recordshots", + "author": "Speaway", + "description": "Screenshot and Record in one plugin: batch stills from camera angles, or in Animate mode export transparent APNG/GIF/PNG sequence or MP4 via FFmpeg. Optional per-texture variant mode for block packs.", + "icon": "photo_camera", + "version": "1.3.1", + "min_version": "4.10.0", + "variant": "desktop", + "tags": ["Minecraft: Java Edition", "Minecraft: Bedrock Edition", "Utility"] + }, + "text_generator": { + "title": "Text Generator", + "author": "Speaway", + "description": "Create stunning 3D block-style text for Minecraft models in Blockbench. Default Minecraft-style letters plus curated block fonts (auto-loaded) or optional custom .ttf/.otf upload, adjustable size and depth, outlines, presets, and full Unicode/emoji support -- perfect for resource packs, maps, and Bedrock/Java block models.", + "icon": "text_fields", + "version": "1.0.0", + "min_version": "4.8.0", + "variant": "both", + "tags": ["Minecraft: Java Edition", "Minecraft: Bedrock Edition", "Font"] + }, + "item_generator": { + "title": "Item Generator", + "author": "Speaway", + "description": "Minecraft block & item generator with live vanilla textures from mcasset.cloud.", + "icon": "fas.fa-cube", + "version": "2.1.0", + "min_version": "4.10.0", + "variant": "desktop", + "await_loading": true, + "tags": ["Minecraft: Java Edition", "Minecraft: Bedrock Edition", "Generator"] + }, "sam3dj": { "title": "sam3DJ", "author": "1Turtle", diff --git a/plugins/item_generator/item_generator.js b/plugins/item_generator/item_generator.js new file mode 100644 index 00000000..345e279b --- /dev/null +++ b/plugins/item_generator/item_generator.js @@ -0,0 +1,1328 @@ +(function () { + "use strict"; + + // --- CSS ---------------------------------------------------------------------- + + const CSS = ` +.igp-root { + display:flex; flex-direction:column; height:100%; + padding:8px; box-sizing:border-box; gap:6px; +} +.igp-search { + width:100%; box-sizing:border-box; + background:var(--color-back); border:1px solid var(--color-border); + border-radius:6px; padding:6px 10px; + color:var(--color-text); font-size:12px; outline:none; + transition:border-color .15s; +} +.igp-search:focus { border-color:var(--color-accent); } +.igp-search:disabled { opacity:0.55; cursor:wait; } +.igp-search::placeholder { color:var(--color-subtle_text,#888); } +.igp-tabs { display:flex; gap:4px; } +.igp-tab { + flex:1; padding:4px 0; text-align:center; + font-size:11px; font-weight:700; letter-spacing:.4px; + border-radius:5px; border:1px solid var(--color-border); + background:transparent; color:var(--color-subtle_text,#888); cursor:pointer; + transition:background .12s, color .12s; +} +.igp-tab:hover:not(:disabled) { background:var(--color-button); color:var(--color-text); } +.igp-tab:disabled { opacity:0.55; cursor:wait; } +.igp-tab.active { background:var(--color-accent); color:#fff; border-color:var(--color-accent); } +.igp-grid { + flex:1; overflow-y:auto; min-height:0; + display:grid; + grid-template-columns:repeat(auto-fill,minmax(72px,1fr)); + gap:6px; align-content:start; +} +.igp-slot { + display:flex; flex-direction:column; align-items:center; + padding:6px 4px 5px; border-radius:6px; + border:1px solid var(--color-border); + background:var(--color-back); + cursor:pointer; user-select:none; + transition:border-color .12s, background .12s; +} +.igp-slot:hover { border-color:var(--color-accent); background:var(--color-button); } +.igp-slot:active { transform:scale(0.95); } +.igp-icon { display:block; image-rendering:pixelated; } +.igp-slot-name { + font-size:9px; color:var(--color-text); text-align:center; + margin-top:4px; line-height:1.2; max-width:68px; + overflow:hidden; text-overflow:ellipsis; white-space:nowrap; +} +.igp-empty { + text-align:center; color:var(--color-subtle_text,#888); + font-size:11px; padding:20px 0; margin:0; grid-column:1/-1; +} +.igp-loading { + flex:1; display:flex; flex-direction:column; align-items:center; + justify-content:center; gap:8px; color:var(--color-subtle_text,#888); + font-size:11px; text-align:center; padding:16px; +} +.igp-loading-spinner { + width:22px; height:22px; border:2px solid var(--color-border); + border-top-color:var(--color-accent); border-radius:50%; + animation:igp-spin .7s linear infinite; +} +@keyframes igp-spin { to { transform:rotate(360deg); } } +.igp-footer { + font-size:9px; color:var(--color-subtle_text,#888); + text-align:center; padding-top:2px; flex-shrink:0; +} +`; + + // --- MCAsset Loader ----------------------------------------------------------- + + var MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; + var MCASSET_CDN = "https://assets.mcasset.cloud"; + var MCASSET_RAW = "https://raw.githubusercontent.com/InventivetalentDev/minecraft-assets"; + var FALLBACK_VERSION = "26.1.2"; + var LS_VERSION_KEY = "igp_mc_version"; + + var CATALOG = { blocks: [], items: [] }; + var mcVersion = FALLBACK_VERSION; + var catalogReady = false; + var catalogError = null; + + function textureUrl(version, category, filename) { + return MCASSET_CDN + "/" + version + "/assets/minecraft/textures/" + + category + "/" + filename; + } + + function listJsonUrl(version, category) { + return MCASSET_RAW + "/" + version + "/assets/minecraft/textures/" + + category + "/_list.json"; + } + + function formatDisplayName(id) { + return id.replace(/_/g, " ").replace(/\b\w/g, function (c) { + return c.toUpperCase(); + }); + } + + function buildEntry(version, category, filename) { + var id = filename.replace(/\.png$/i, ""); + return { + id: id, + name: formatDisplayName(id), + file: filename, + category: category, + version: version, + url: textureUrl(version, category, filename), + tags: id.split("_"), + }; + } + + // --- Block Face Grouping ------------------------------------------------------ + + var FACE_SUFFIXES = ["top", "bottom", "north", "south", "east", "west", "side", "front", "back"]; + var FACE_TO_BB = { + top: "up", bottom: "down", north: "north", south: "south", + east: "east", west: "west", side: "side", front: "north", back: "south", + }; + + function isFaceVariantSuffix(suffix) { + if (!suffix) return false; + var part = suffix.split("_")[0]; + return FACE_SUFFIXES.indexOf(part) !== -1; + } + + function parseBlockTexture(id, allIds) { + var faceMatch = id.match(/^(.+)_(top|bottom|north|south|east|west|side|front|back)(?:_(.+))?$/); + if (faceMatch && !/_stage\d/.test(id)) { + return { base: faceMatch[1], face: faceMatch[2], state: faceMatch[3] || null }; + } + var prefix = id + "_"; + var hasFaceVariants = false; + allIds.forEach(function (other) { + if (other.indexOf(prefix) !== 0) return; + if (isFaceVariantSuffix(other.slice(prefix.length))) hasFaceVariants = true; + }); + if (hasFaceVariants) return { base: id, face: "side", state: null }; + return { base: id, face: null, state: null }; + } + + function statePriority(state) { + if (!state) return 0; + if (/inactive|off/.test(state)) return 1; + if (/active|on|lit/.test(state)) return 2; + return 10; + } + + function pickDefaultFaces(textures) { + var buckets = { up: [], down: [], north: [], south: [], east: [], west: [], side: [] }; + var uniformUrl = null; + + textures.forEach(function (t) { + if (!t.face) { + uniformUrl = t.entry.url; + return; + } + var bbFace = FACE_TO_BB[t.face] || t.face; + if (!buckets[bbFace]) buckets[bbFace] = []; + buckets[bbFace].push({ url: t.entry.url, state: t.state }); + }); + + if (uniformUrl) { + return { + up: uniformUrl, down: uniformUrl, + north: uniformUrl, south: uniformUrl, + east: uniformUrl, west: uniformUrl, + }; + } + + function best(candidates) { + if (!candidates || !candidates.length) return null; + candidates.sort(function (a, b) { return statePriority(a.state) - statePriority(b.state); }); + return candidates[0].url; + } + + var faces = { + up: best(buckets.up), + down: best(buckets.down), + north: best(buckets.north), + south: best(buckets.south), + east: best(buckets.east), + west: best(buckets.west), + }; + var sideUrl = best(buckets.side); + if (sideUrl) { + ["north", "south", "east", "west"].forEach(function (f) { + if (!faces[f]) faces[f] = sideUrl; + }); + } + var fallback = sideUrl || faces.up || faces.north || faces.down + || (textures[0] && textures[0].entry.url); + ["up", "down", "north", "south", "east", "west"].forEach(function (f) { + if (!faces[f]) faces[f] = fallback; + }); + return faces; + } + + function groupBlockEntries(version, filenames) { + var entries = filenames + .filter(function (f) { return /\.png$/i.test(f) && !/\.mcmeta$/i.test(f); }) + .map(function (f) { return buildEntry(version, "block", f); }); + + // Beds: show only _head variants renamed to base name; hide _foot and _up entries + entries = entries + .filter(function (e) { return !/_bed_(foot|up)/.test(e.id) && !/_bed_foot$/.test(e.id); }) + .map(function (e) { + if (/_bed_head$/.test(e.id)) { + var baseId = e.id.replace(/_head$/, ''); + return Object.assign({}, e, { id: baseId, name: formatDisplayName(baseId) }); + } + return e; + }); + + var allIds = new Set(entries.map(function (e) { return e.id; })); + var groups = {}; + + entries.forEach(function (entry) { + var parsed = parseBlockTexture(entry.id, allIds); + var base = parsed.base; + if (!groups[base]) groups[base] = []; + groups[base].push({ + entry: entry, + face: parsed.face, + state: parsed.state, + }); + }); + + return Object.keys(groups).map(function (base) { + var textures = groups[base]; + var faces = pickDefaultFaces(textures); + var multiFace = textures.length > 1; + var previewUrl = faces.up || faces.side || faces.north || textures[0].entry.url; + return { + id: base, + name: formatDisplayName(base), + category: "block", + version: version, + url: previewUrl, + faces: faces, + multiFace: multiFace, + tags: base.split("_"), + }; + }).sort(function (a, b) { return a.name.localeCompare(b.name); }); + } + + function fetchListJson(version, category) { + return fetch(listJsonUrl(version, category)) + .then(function (res) { + if (!res.ok) return null; + return res.json(); + }) + .then(function (data) { + if (!data || !data.files || !data.files.length) return null; + return data; + }) + .catch(function () { return null; }); + } + + function resolveVersion() { + return fetch(MANIFEST_URL) + .then(function (res) { return res.json(); }) + .then(function (manifest) { + var candidates = []; + if (manifest.latest && manifest.latest.release) { + candidates.push(manifest.latest.release); + } + if (manifest.versions) { + manifest.versions + .filter(function (v) { return v.type === "release"; }) + .slice(0, 20) + .forEach(function (v) { + if (candidates.indexOf(v.id) === -1) candidates.push(v.id); + }); + } + if (candidates.indexOf(FALLBACK_VERSION) === -1) { + candidates.push(FALLBACK_VERSION); + } + return tryVersionCandidates(candidates, 0); + }) + .catch(function () { + return tryVersionCandidates([FALLBACK_VERSION], 0); + }); + } + + function tryVersionCandidates(candidates, index) { + if (index >= candidates.length) return Promise.resolve(FALLBACK_VERSION); + var version = candidates[index]; + return fetchListJson(version, "block").then(function (blockList) { + if (blockList) return version; + return tryVersionCandidates(candidates, index + 1); + }); + } + + function buildCatalog(version) { + return Promise.all([ + fetchListJson(version, "block"), + fetchListJson(version, "item"), + ]).then(function (results) { + var blockList = results[0]; + var itemList = results[1]; + if (!blockList && !itemList) { + throw new Error("Could not load texture catalog for MC " + version); + } + CATALOG.blocks = groupBlockEntries(version, blockList ? blockList.files : []); + CATALOG.items = (itemList ? itemList.files : []) + .filter(function (f) { return /\.png$/i.test(f) && !/\.mcmeta$/i.test(f); }) + .map(function (f) { return buildEntry(version, "item", f); }) + .sort(function (a, b) { return a.name.localeCompare(b.name); }); + mcVersion = version; + catalogReady = true; + try { + localStorage.setItem(LS_VERSION_KEY, version); + } catch (e) { /* ignore */ } + return version; + }); + } + + var McAssetLoader = { + init: function () { + catalogReady = false; + catalogError = null; + CATALOG.blocks = []; + CATALOG.items = []; + return resolveVersion() + .then(function (version) { return buildCatalog(version); }) + .catch(function (err) { + catalogError = err.message || String(err); + console.warn("[IGP] Catalog load failed:", catalogError); + return buildCatalog(FALLBACK_VERSION).catch(function () { + catalogReady = true; + throw err; + }); + }); + }, + getVersion: function () { return mcVersion; }, + isReady: function () { return catalogReady; }, + getError: function () { return catalogError; }, + }; + + // --- Texture Cache & Resolver ------------------------------------------------- + + var textureCache = new Map(); + var textureCacheOrder = []; + var TEXTURE_CACHE_MAX = 200; + + function cacheTexture(url, dataUrl) { + if (textureCache.has(url)) { + var existing = textureCacheOrder.indexOf(url); + if (existing >= 0) textureCacheOrder.splice(existing, 1); + } else if (textureCacheOrder.length >= TEXTURE_CACHE_MAX) { + var old = textureCacheOrder.shift(); + textureCache.delete(old); + } + textureCache.set(url, dataUrl); + textureCacheOrder.push(url); + } + + function blobToDataURL(blob) { + return new Promise(function (resolve, reject) { + var reader = new FileReader(); + reader.onloadend = function () { resolve(reader.result); }; + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + function resolveTexture(url) { + if (textureCache.has(url)) { + return Promise.resolve(textureCache.get(url)); + } + return fetch(url) + .then(function (res) { + if (!res.ok) throw new Error("HTTP " + res.status); + return res.blob(); + }) + .then(blobToDataURL) + .then(function (dataUrl) { + cacheTexture(url, dataUrl); + return dataUrl; + }); + } + + function clearTextureCache() { + textureCache.clear(); + textureCacheOrder.length = 0; + } + + // --- Texture Helpers ---------------------------------------------------------- + + function createAndApplyTexture(cubes, name, hexColor) { + try { + if (typeof Texture === "undefined") return; + var tc = document.createElement("canvas"); + tc.width = 16; + tc.height = 16; + var tctx = tc.getContext("2d"); + tctx.fillStyle = hexColor; + tctx.fillRect(0, 0, 16, 16); + var noisePixels = [ + [1,2],[4,1],[7,3],[10,0],[13,2],[15,4], + [0,5],[3,7],[6,5],[9,8],[12,6],[14,8], + [2,10],[5,12],[8,10],[11,13],[1,14],[4,15], + [7,12],[10,14],[13,11],[15,15],[0,12],[6,14], + ]; + tctx.fillStyle = "rgba(0,0,0,0.13)"; + noisePixels.forEach(function (p) { tctx.fillRect(p[0], p[1], 1, 1); }); + var tex = new Texture({ name: name.replace(/\s+/g, "_") + "_tex" }); + tex.fromDataURL(tc.toDataURL("image/png")); + tex.add(false, false); + applyTextureToCubes(cubes, tex.uuid); + } catch (e) { + console.warn("[IGP] Fallback texture error:", e.message); + } + } + + function applyTextureToCubes(cubes, uuid) { + cubes.forEach(function (cube) { + if (!cube || !cube.faces) return; + Object.keys(cube.faces).forEach(function (face) { + if (cube.faces[face]) { + cube.faces[face].uv = [0, 0, 16, 16]; + cube.faces[face].texture = uuid; + } + }); + }); + } + + function applyVanillaTexture(cubes, name, url, fallbackHex) { + fallbackHex = fallbackHex || "#9B9B9B"; + return resolveTexture(url) + .then(function (dataUrl) { + if (typeof Texture === "undefined") return; + var tex = new Texture({ name: name.replace(/\s+/g, "_") + "_tex" }); + tex.fromDataURL(dataUrl); + tex.add(false, false); + applyTextureToCubes(cubes, tex.uuid); + }) + .catch(function (e) { + console.warn("[IGP] Vanilla texture failed, using fallback:", e.message); + Blockbench.showQuickMessage("Texture unavailable -- using placeholder", 2000); + createAndApplyTexture(cubes, name, fallbackHex); + }); + } + + function applyFaceTextures(cube, faces, name) { + var bbFaces = ["north", "south", "east", "west", "up", "down"]; + var promises = bbFaces.map(function (face) { + var url = faces[face]; + if (!url || !cube.faces[face]) return Promise.resolve(); + return resolveTexture(url).then(function (dataUrl) { + if (typeof Texture === "undefined") return; + var tex = new Texture({ name: name.replace(/\s+/g, "_") + "_" + face }); + tex.fromDataURL(dataUrl); + tex.add(false, false); + cube.faces[face].uv = [0, 0, 16, 16]; + cube.faces[face].texture = tex.uuid; + }); + }); + return Promise.all(promises); + } + + function applyTexturesToCubes(cubes, label, entry) { + if (entry.multiFace && entry.faces) { + return Promise.all(cubes.map(function (cube) { + return applyFaceTextures(cube, entry.faces, label); + })).catch(function (e) { + console.warn("[IGP] Multi-face texture failed, using fallback:", e.message); + Blockbench.showQuickMessage("Texture unavailable -- using placeholder", 2000); + createAndApplyTexture(cubes, label, "#9B9B9B"); + }); + } + return applyVanillaTexture(cubes, label, entry.url); + } + + function drawTextureIcon(canvas, url) { + resolveTexture(url).then(function (dataUrl) { + var img = new Image(); + img.onload = function () { + var ctx = canvas.getContext("2d"); + ctx.imageSmoothingEnabled = false; + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + }; + img.src = dataUrl; + }).catch(function () { + var ctx = canvas.getContext("2d"); + ctx.fillStyle = "#555"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + }); + } + + // --- Spawn Helpers ------------------------------------------------------------ + + var SPAWN_PIVOT = [-8, 0, -8]; + + function getGridOffset() { + return { ox: -8, oz: -8 }; + } + + function makeGroup(name, pivot) { + return new Group({ name: name, pivot: pivot.slice() }).init(); + } + + function makeCube(name, from, to, pivot) { + return new Cube({ name: name, from: from, to: to, pivot: pivot || [8, 8, 8] }).init(); + } + + function autoUV(cube) { + var sx = cube.to[0] - cube.from[0]; + var sy = cube.to[1] - cube.from[1]; + var sz = cube.to[2] - cube.from[2]; + var uvs = { + north: [sz, sz, sz + sx, sz + sy], + south: [sz + sx + sz, sz, sz + sx + sz + sx, sz + sy], + east: [0, sz, sz, sz + sy], + west: [sz + sx, sz, sz + sx + sz, sz + sy], + up: [sz, 0, sz + sx, sz], + down: [sz + sx, 0, sz + sx + sx, sz], + }; + Object.entries(uvs).forEach(function (e) { + if (cube.faces[e[0]]) { cube.faces[e[0]].uv = e[1]; cube.faces[e[0]].texture = null; } + }); + } + + function finishSpawn(cubes, label, entry, editName) { + return applyTexturesToCubes(cubes, label, entry).then(function () { + Canvas.updateAll(); + Undo.finishEdit(editName || ("Spawned " + label)); + }); + } + + // --- Block Spawns ------------------------------------------------------------- + + function spawnStandardBlock(label, entry) { + label = label || "Block"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + var c = makeCube(label, [ox, 0, oz], [ox+16, 16, oz+16], [ox+8, 8, oz+8]); + autoUV(c); + return finishSpawn([c], label, entry); + } + + function spawnSlab(label, entry) { + label = label || "Slab"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + var c = makeCube(label, [ox, 0, oz], [ox+16, 8, oz+16], [ox+8, 4, oz+8]); + autoUV(c); + return finishSpawn([c], label, entry); + } + + function spawnStairs(label, entry) { + label = label || "Stairs"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + var g = makeGroup(label, SPAWN_PIVOT); + var bot = makeCube("bottom", [ox, 0, oz], [ox+16, 8, oz+16], [ox+8, 4, oz+8]); + var top = makeCube("top_step", [ox, 8, oz], [ox+16, 16, oz+8], [ox+8, 12, oz+4]); + autoUV(bot); autoUV(top); + bot.addTo(g); top.addTo(g); + return finishSpawn([bot, top], label, entry); + } + + function spawnWall(label, entry) { + label = label || "Wall"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + var c = makeCube(label, [ox+4, 0, oz+4], [ox+12, 16, oz+12], [ox+8, 8, oz+8]); + autoUV(c); + return finishSpawn([c], label, entry); + } + + function spawnFence(label, entry) { + label = label || "Fence"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + var g = makeGroup(label, SPAWN_PIVOT); + var cubes = [ + makeCube("post", [ox+6, 0, oz+6], [ox+10, 16, oz+10], [ox+8, 8, oz+8]), + makeCube("plank_tn", [ox+7, 9, oz], [ox+9, 13, oz+6], [ox+8, 11, oz+3]), + makeCube("plank_ts", [ox+7, 9, oz+10], [ox+9, 13, oz+16], [ox+8, 11, oz+13]), + makeCube("plank_bn", [ox+7, 3, oz], [ox+9, 7, oz+6], [ox+8, 5, oz+3]), + makeCube("plank_bs", [ox+7, 3, oz+10], [ox+9, 7, oz+16], [ox+8, 5, oz+13]), + ]; + cubes.forEach(function (c) { autoUV(c); c.addTo(g); }); + return finishSpawn(cubes, label, entry); + } + + function spawnTrapdoor(label, entry) { + label = label || "Trapdoor"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + var c = makeCube(label, [ox, 0, oz], [ox+16, 3, oz+16], [ox+8, 1.5, oz+8]); + autoUV(c); + return finishSpawn([c], label, entry); + } + + function spawnHeavyCore(label, entry) { + label = label || "Heavy Core"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + var c = makeCube(label, [ox+3, 3, oz+3], [ox+13, 13, oz+13], [ox+8, 8, oz+8]); + autoUV(c); + return finishSpawn([c], label, entry); + } + + // --- Item Spawns -------------------------------------------------------------- + + function spawnExtrudedItem(label, entry) { + label = label || "Item"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + + return resolveTexture(entry.url).then(function (dataUrl) { + return new Promise(function (resolve, reject) { + var img = new Image(); + img.onload = function () { resolve({ img: img, dataUrl: dataUrl }); }; + img.onerror = reject; + img.src = dataUrl; + }); + }).then(function (result) { + var img = result.img; + var W = img.width; + var H = img.height; + + var offscreen = document.createElement("canvas"); + offscreen.width = W; offscreen.height = H; + var ctx = offscreen.getContext("2d"); + ctx.drawImage(img, 0, 0); + var data = ctx.getImageData(0, 0, W, H).data; + + Undo.initEdit({ elements: [], outliner: true }); + + var tex = new Texture({ name: label.replace(/\s+/g, "_") + "_tex" }); + tex.fromDataURL(result.dataUrl); + tex.add(false, false); + + var g = makeGroup(label, SPAWN_PIVOT); + var cubes = []; + + for (var iy = 0; iy < H; iy++) { + for (var ix = 0; ix < W; ix++) { + var a = data[(iy * W + ix) * 4 + 3]; + if (a < 10) continue; + + var worldY = H - 1 - iy; + var c = makeCube("cube", + [ox + ix, worldY, oz], + [ox + ix + 1, worldY + 1, oz + 1], + [ox + ix + 0.5, worldY + 0.5, oz + 0.5] + ); + var u0 = ix, v0 = iy, u1 = ix+1, v1 = iy+1; + Object.keys(c.faces).forEach(function (f) { + c.faces[f].uv = [u0, v0, u1, v1]; + c.faces[f].texture = tex.uuid; + }); + if (c.faces.south) c.faces.south.uv = [u1, v0, u0, v1]; + c.addTo(g); + cubes.push(c); + } + } + + Canvas.updateAll(); + Undo.finishEdit("Spawned " + label); + return cubes; + }).catch(function (e) { + console.warn("[IGP] Extrude failed:", e); + Undo.initEdit({ elements: [], outliner: true }); + var c = makeCube(label, [ox, 0, oz], [ox+16, 16, oz+1], [ox+8, 8, oz+0.5]); + createAndApplyTexture([c], label, "#9B9B9B"); + Canvas.updateAll(); + Undo.finishEdit("Spawned " + label); + }); + } + + function spawnFlatItem(label, entry) { return spawnExtrudedItem(label, entry); } + + function spawnHandheldItem(label, entry) { + label = label || "Handheld Item"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + + return resolveTexture(entry.url).then(function (dataUrl) { + return new Promise(function (resolve, reject) { + var img = new Image(); + img.onload = function () { resolve({ img: img, dataUrl: dataUrl }); }; + img.onerror = reject; + img.src = dataUrl; + }); + }).then(function (result) { + var img = result.img; + var W = img.width; + var H = img.height; + + var offscreen = document.createElement("canvas"); + offscreen.width = W; offscreen.height = H; + var ctx = offscreen.getContext("2d"); + ctx.drawImage(img, 0, 0); + var data = ctx.getImageData(0, 0, W, H).data; + + Undo.initEdit({ elements: [], outliner: true }); + + var tex = new Texture({ name: label.replace(/\s+/g, "_") + "_tex" }); + tex.fromDataURL(result.dataUrl); + tex.add(false, false); + + var g = makeGroup(label, SPAWN_PIVOT); + var cubes = []; + + [0, 1].forEach(function (zi) { + for (var iy = 0; iy < H; iy++) { + for (var ix = 0; ix < W; ix++) { + var a = data[(iy * W + ix) * 4 + 3]; + if (a < 10) continue; + var worldY = H - 1 - iy; + var c = makeCube("cube", + [ox+ix, worldY, oz+zi], + [ox+ix+1, worldY+1, oz+zi+1], + [ox+ix+0.5, worldY+0.5, oz+zi+0.5] + ); + var u0 = ix, v0 = iy, u1 = ix+1, v1 = iy+1; + Object.keys(c.faces).forEach(function (f) { + c.faces[f].uv = [u0, v0, u1, v1]; + c.faces[f].texture = tex.uuid; + }); + if (c.faces.south) c.faces.south.uv = [u1, v0, u0, v1]; + c.addTo(g); + cubes.push(c); + } + } + }); + + Canvas.updateAll(); + Undo.finishEdit("Spawned " + label); + return cubes; + }).catch(function (e) { + console.warn("[IGP] Handheld extrude failed:", e); + Undo.initEdit({ elements: [], outliner: true }); + var c = makeCube(label, [ox, 0, oz], [ox+16, 16, oz+2], [ox+8, 8, oz+1]); + createAndApplyTexture([c], label, "#9B9B9B"); + Canvas.updateAll(); + Undo.finishEdit("Spawned " + label); + }); + } + + function spawnMace(label, entry) { + label = label || "Mace"; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + var g = makeGroup(label, SPAWN_PIVOT); + var head = makeCube("mace_head", [ox+4, 9, oz], [ox+12, 16, oz+2], [ox+8, 12, oz+1]); + var shaft = makeCube("shaft", [ox+6, 0, oz], [ox+10, 9, oz+2], [ox+8, 4.5, oz+1]); + autoUV(head); autoUV(shaft); + head.addTo(g); shaft.addTo(g); + return finishSpawn([head, shaft], label, entry); + } + + // --- Model JSON Loader -------------------------------------------------------- + + var modelCache = new Map(); + + function fetchJson(url) { + return fetch(url) + .then(function (res) { return res.ok ? res.json() : null; }) + .catch(function () { return null; }); + } + + function mergeModel(parent, child) { + return { + parent: child.parent, + textures: Object.assign({}, parent.textures || {}, child.textures || {}), + elements: child.elements || parent.elements, + }; + } + + function resolveModelChain(version, modelRef, depth) { + depth = depth || 0; + if (depth > 10) return Promise.resolve({}); + if (modelCache.has(modelRef)) return Promise.resolve(modelCache.get(modelRef)); + + var path = modelRef.replace(/^minecraft:/, ''); + var url = MCASSET_RAW + "/" + version + "/assets/minecraft/models/" + path + ".json"; + + return fetchJson(url).then(function (model) { + if (!model) return {}; + if (!model.parent || model.parent.startsWith('builtin/')) { + modelCache.set(modelRef, model); + return model; + } + return resolveModelChain(version, model.parent, depth + 1).then(function (parent) { + var merged = mergeModel(parent, model); + modelCache.set(modelRef, merged); + return merged; + }); + }); + } + + function resolveTexRef(textures, ref) { + if (!ref) return null; + var seen = new Set(); + while (ref && ref.startsWith('#')) { + var key = ref.slice(1); + if (seen.has(key) || !textures[key]) return null; + seen.add(key); + ref = textures[key]; + } + return ref; + } + + function texRefToUrl(version, ref) { + if (!ref) return null; + return MCASSET_CDN + "/" + version + "/assets/minecraft/textures/" + ref.replace(/^minecraft:/, '') + ".png"; + } + + // --- Model-Based Spawner ------------------------------------------------------ + + function spawnFromModelJson(label, model, version, fallbackEntry) { + var textures = model.textures || {}; + var elements = model.elements; + var parent = model.parent || ''; + + // Flat/handheld items (no elements, uses layer0 texture) + if (!elements) { + var isHandheld = /handheld/.test(parent); + var layer0ref = resolveTexRef(textures, '#layer0'); + var texUrl = layer0ref ? texRefToUrl(version, layer0ref) : fallbackEntry.url; + var fakeEntry = Object.assign({}, fallbackEntry, { url: texUrl }); + return isHandheld ? spawnHandheldItem(label, fakeEntry) : spawnExtrudedItem(label, fakeEntry); + } + + // Collect all unique texture refs used in elements + var texRefs = {}; + elements.forEach(function (el) { + Object.keys(el.faces || {}).forEach(function (f) { + var resolved = resolveTexRef(textures, el.faces[f].texture); + if (resolved) texRefs[resolved] = texRefToUrl(version, resolved); + }); + }); + + // Load textures in parallel + var loadPromises = Object.keys(texRefs).map(function (ref) { + return resolveTexture(texRefs[ref]) + .then(function (dataUrl) { return { ref: ref, dataUrl: dataUrl }; }) + .catch(function () { return { ref: ref, dataUrl: null }; }); + }); + + return Promise.all(loadPromises).then(function (results) { + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + Undo.initEdit({ elements: [], outliner: true }); + + // Create one BB Texture per unique texture + var bbTexMap = {}; + results.forEach(function (r) { + if (!r.dataUrl) return; + var tex = new Texture({ name: r.ref.replace(/[:/]/g, '_') }); + tex.fromDataURL(r.dataUrl); + tex.add(false, false); + bbTexMap[r.ref] = tex.uuid; + }); + + var g = elements.length > 1 ? makeGroup(label, SPAWN_PIVOT) : null; + var cubes = []; + + elements.forEach(function (el) { + var from = [el.from[0] + ox, el.from[1], el.from[2] + oz]; + var to = [el.to[0] + ox, el.to[1], el.to[2] + oz]; + var pivot = [(from[0]+to[0])/2, (from[1]+to[1])/2, (from[2]+to[2])/2]; + + var c = makeCube("cube", from, to, pivot); + + // Element rotation + if (el.rotation) { + var r = el.rotation; + c.rotation = [ + r.axis === 'x' ? -r.angle : 0, + r.axis === 'y' ? -r.angle : 0, + r.axis === 'z' ? -r.angle : 0, + ]; + c.origin = r.origin + ? [r.origin[0] + ox, r.origin[1], r.origin[2] + oz] + : pivot; + } + + // Thin element detection (cross model, flowers, etc.) + var thickX = Math.abs(el.to[0] - el.from[0]); + var thickZ = Math.abs(el.to[2] - el.from[2]); + + // Faces + Object.keys(c.faces).forEach(function (f) { + var modelFace = (el.faces || {})[f]; + if (!modelFace) { + c.faces[f].texture = null; + c.faces[f].uv = [0, 0, 0, 0]; + return; + } + var resolved = resolveTexRef(textures, modelFace.texture); + c.faces[f].texture = resolved ? (bbTexMap[resolved] || null) : null; + var uv = modelFace.uv ? modelFace.uv.slice() : [0, 0, 16, 16]; + // Flip UV horizontally on the opposite face of thin elements (mirror correction) + if (thickZ < 2 && f === 'south') uv = [uv[2], uv[1], uv[0], uv[3]]; + if (thickX < 2 && f === 'east') uv = [uv[2], uv[1], uv[0], uv[3]]; + c.faces[f].uv = uv; + if (modelFace.rotation) c.faces[f].rotation = modelFace.rotation; + }); + + if (g) c.addTo(g); + cubes.push(c); + }); + + Canvas.updateAll(); + Undo.finishEdit("Spawned " + label); + return cubes; + }); + } + + // --- Bed Spawner (head + foot as one group, 32x16) --- + + function mirrorModelZ(model) { + if (!model.elements) return model; + return Object.assign({}, model, { + elements: model.elements.map(function (el) { + var e = Object.assign({}, el); + e.from = [el.from[0], el.from[1], 16 - el.to[2]]; + e.to = [el.to[0], el.to[1], 16 - el.from[2]]; + if (el.faces) { + e.faces = Object.assign({}, el.faces); + var origNorth = el.faces.north; + var origSouth = el.faces.south; + if (origSouth) e.faces.north = origSouth; else delete e.faces.north; + if (origNorth) e.faces.south = origNorth; else delete e.faces.south; + } + if (el.rotation && el.rotation.origin) { + e.rotation = Object.assign({}, el.rotation); + e.rotation.origin = [el.rotation.origin[0], el.rotation.origin[1], 16 - el.rotation.origin[2]]; + if (el.rotation.axis === 'z') e.rotation.angle = -el.rotation.angle; + } + return e; + }) + }); + } + + function applyModelElementsToCubes(modelParts, ox, oz, g, bbTexMap) { + modelParts.forEach(function (pt) { + var textures = pt.model.textures || {}; + (pt.model.elements || []).forEach(function (el) { + var from = [el.from[0] + ox, el.from[1], el.from[2] + oz + pt.zOff]; + var to = [el.to[0] + ox, el.to[1], el.to[2] + oz + pt.zOff]; + var pivot = [(from[0]+to[0])/2, (from[1]+to[1])/2, (from[2]+to[2])/2]; + var c = makeCube("cube", from, to, pivot); + var thickX = Math.abs(el.to[0] - el.from[0]); + var thickZ = Math.abs(el.to[2] - el.from[2]); + + Object.keys(c.faces).forEach(function (f) { + var mf = (el.faces || {})[f]; + if (!mf) { c.faces[f].texture = null; c.faces[f].uv = [0,0,0,0]; return; } + var resolved = resolveTexRef(textures, mf.texture); + c.faces[f].texture = resolved ? (bbTexMap[resolved] || null) : null; + var uv = mf.uv ? mf.uv.slice() : [0, 0, 16, 16]; + if (thickZ < 2 && f === 'south') uv = [uv[2], uv[1], uv[0], uv[3]]; + if (thickX < 2 && f === 'east') uv = [uv[2], uv[1], uv[0], uv[3]]; + c.faces[f].uv = uv; + if (mf.rotation) c.faces[f].rotation = mf.rotation; + }); + + if (el.rotation) { + var r = el.rotation; + c.rotation = [r.axis==='x'?-r.angle:0, r.axis==='y'?-r.angle:0, r.axis==='z'?-r.angle:0]; + c.origin = r.origin ? [r.origin[0]+ox, r.origin[1], r.origin[2]+oz+pt.zOff] : pivot; + } + c.addTo(g); + }); + }); + } + + function collectTexRefs(modelParts, version) { + var allTexRefs = {}; + modelParts.forEach(function (pt) { + var textures = pt.model.textures || {}; + (pt.model.elements || []).forEach(function (el) { + Object.keys(el.faces || {}).forEach(function (f) { + var resolved = resolveTexRef(textures, el.faces[f].texture); + if (resolved) allTexRefs[resolved] = texRefToUrl(version, resolved); + }); + }); + }); + return allTexRefs; + } + + function loadTexMapFromRefs(allTexRefs) { + return Promise.all(Object.keys(allTexRefs).map(function (ref) { + return resolveTexture(allTexRefs[ref]) + .then(function (d) { return { ref: ref, dataUrl: d }; }) + .catch(function () { return { ref: ref, dataUrl: null }; }); + })).then(function (results) { + var bbTexMap = {}; + results.forEach(function (r) { + if (!r.dataUrl) return; + var tex = new Texture({ name: r.ref.replace(/[:/]/g, '_') }); + tex.fromDataURL(r.dataUrl); + tex.add(false, false); + bbTexMap[r.ref] = tex.uuid; + }); + return bbTexMap; + }); + } + + // Fetch the model path for a specific blockstate variant + function fetchBedModelPaths(version, id) { + var bsUrl = MCASSET_RAW + "/" + version + "/assets/minecraft/blockstates/" + id + ".json"; + return fetchJson(bsUrl).then(function (bs) { + if (!bs || !bs.variants) return { foot: null, head: null }; + var footPath = null, headPath = null; + Object.keys(bs.variants).forEach(function (key) { + var m = bs.variants[key]; + var modelRef = (Array.isArray(m) ? m[0] : m).model || ''; + modelRef = modelRef.replace(/^minecraft:/, ''); + if (/part=foot/.test(key) && /facing=north/.test(key)) footPath = modelRef; + if (/part=head/.test(key) && /facing=north/.test(key)) headPath = modelRef; + }); + // Fallback: any facing or first match + if (!footPath || !headPath) { + Object.keys(bs.variants).forEach(function (key) { + var m = bs.variants[key]; + var modelRef = (Array.isArray(m) ? m[0] : m).model || ''; + modelRef = modelRef.replace(/^minecraft:/, ''); + if (!footPath && /part=foot/.test(key)) footPath = modelRef; + if (!headPath && /part=head/.test(key)) headPath = modelRef; + }); + } + return { foot: footPath, head: headPath }; + }).catch(function () { return { foot: null, head: null }; }); + } + + function spawnBed(label, entry) { + var version = entry.version || mcVersion; + var id = entry.id; + var p = getGridOffset(); var ox = p.ox, oz = p.oz; + + return fetchBedModelPaths(version, id).then(function (paths) { + var footRef = paths.foot || ('block/' + id); + var headRef = paths.head || ('block/' + id); + + return Promise.all([ + resolveModelChain(version, footRef).catch(function () { return {}; }), + resolveModelChain(version, headRef).catch(function () { return {}; }), + ]).then(function (models) { + var footModel = models[0]; + var headModel = models[1]; + var hasElements = (footModel.elements && footModel.elements.length) || + (headModel.elements && headModel.elements.length); + + if (!hasElements) { + // No elements in model JSON -- fall back to two simple cubes + return resolveTexture(entry.url).then(function (dataUrl) { + Undo.initEdit({ elements: [], outliner: true }); + var tex = new Texture({ name: label.replace(/\s+/g, '_') + '_tex' }); + tex.fromDataURL(dataUrl); + tex.add(false, false); + var g = makeGroup(label, SPAWN_PIVOT); + var foot = makeCube("foot", [ox, 0, oz], [ox+16, 9, oz+16], [ox+8, 4.5, oz+8]); + var head = makeCube("head", [ox, 0, oz+16], [ox+16, 9, oz+32], [ox+8, 4.5, oz+24]); + [foot, head].forEach(function (c) { + Object.keys(c.faces).forEach(function (f) { + c.faces[f].uv = [0, 0, 16, 16]; + c.faces[f].texture = tex.uuid; + }); + c.addTo(g); + }); + Canvas.updateAll(); + Undo.finishEdit("Spawned " + label); + }); + } + + var modelParts = [ + { model: headModel, zOff: 0 }, + { model: footModel, zOff: 16 }, + ]; + + var allTexRefs = collectTexRefs(modelParts, version); + return loadTexMapFromRefs(allTexRefs).then(function (bbTexMap) { + Undo.initEdit({ elements: [], outliner: true }); + var g = makeGroup(label, SPAWN_PIVOT); + applyModelElementsToCubes(modelParts, ox, oz, g, bbTexMap); + Canvas.updateAll(); + Undo.finishEdit("Spawned " + label); + }); + }); + }); + } + + // --- Spawn Router ------------------------------------------------------------- + + function spawnFromEntry(entry) { + var label = entry.name; + var version = entry.version || mcVersion; + var id = entry.id; + + // Beds: merge head/foot/base variants into a single group + var bedBase = id.replace(/_(head|foot)$/, ''); + if (entry.category === 'block' && /_bed$/.test(bedBase)) { + var bedEntry = Object.assign({}, entry, { id: bedBase, name: formatDisplayName(bedBase) }); + return spawnBed(formatDisplayName(bedBase), bedEntry); + } + + return resolveModelChain(version, entry.category + "/" + id) + .then(function (model) { + if (model && (model.elements || model.textures)) { + return spawnFromModelJson(label, model, version, entry); + } + return spawnFromEntry_fallback(entry); + }) + .catch(function () { return spawnFromEntry_fallback(entry); }); + } + + function spawnFromEntry_fallback(entry) { + var label = entry.name; + var id = entry.id; + var cat = entry.category; + if (cat === "item") return spawnExtrudedItem(label, entry); + if (/heavy_core/.test(id)) return spawnHeavyCore(label, entry); + if (/trapdoor/.test(id)) return spawnTrapdoor(label, entry); + if (/fence/.test(id) && !/gate/.test(id)) return spawnFence(label, entry); + if (/slab/.test(id)) return spawnSlab(label, entry); + if (/stairs/.test(id)) return spawnStairs(label, entry); + if (/_wall$|^wall$|_wall_/.test(id)) return spawnWall(label, entry); + return spawnStandardBlock(label, entry); + } + + // --- Lazy Icon Observer ------------------------------------------------------- + + var iconObserver = null; + + function setupIconObserver(grid) { + if (!grid || typeof IntersectionObserver === "undefined") { + grid.querySelectorAll("canvas.igp-icon").forEach(function (canvas) { + var url = canvas.getAttribute("data-url"); + if (url) drawTextureIcon(canvas, url); + }); + return; + } + if (iconObserver) iconObserver.disconnect(); + iconObserver = new IntersectionObserver(function (entries) { + entries.forEach(function (ent) { + if (!ent.isIntersecting) return; + var canvas = ent.target; + var url = canvas.getAttribute("data-url"); + if (!url || canvas.dataset.loaded) return; + canvas.dataset.loaded = "1"; + drawTextureIcon(canvas, url); + iconObserver.unobserve(canvas); + }); + }, { root: grid, rootMargin: "100px" }); + grid.querySelectorAll("canvas.igp-icon").forEach(function (canvas) { + canvas.dataset.loaded = ""; + if (canvas.getAttribute("data-url")) iconObserver.observe(canvas); + }); + } + + // --- Plugin ------------------------------------------------------------------- + + var styleEl = null; + var igpPanel = null; + var queryDebounceTimer = null; + + function createPanel() { + igpPanel = new Panel("item_generator", { + name: "Item Generator", + icon: "fas.fa-cube", + default_position: { slot: "right_bar" }, + growable: true, + resizable: true, + component: { + template: [ + '
', + '
', + '
', + ' {{ loadingText }}', + '
', + ' ', + '
', + ].join("\n"), + + data: function () { + return { + ready: catalogReady, + loadingText: "Loading Minecraft textures...", + queryInput: "", + query: "", + cat: "blocks", + }; + }, + + computed: { + tabList: function () { + return [ + { id: "blocks", label: "BLOCKS (" + CATALOG.blocks.length + ")" }, + { id: "items", label: "ITEMS (" + CATALOG.items.length + ")" }, + ]; + }, + filtered: function () { + var q = this.query.toLowerCase().trim(); + var items = CATALOG[this.cat] || []; + if (!q) return items; + return items.filter(function (entry) { + if (entry.name.toLowerCase().indexOf(q) !== -1) return true; + if (entry.id.indexOf(q) !== -1) return true; + return entry.tags.some(function (tag) { return tag.indexOf(q) !== -1; }); + }); + }, + footerText: function () { + var text = "MC " + mcVersion + " * mcasset.cloud"; + if (McAssetLoader.getError()) text += " * " + McAssetLoader.getError(); + return text; + }, + }, + + mounted: function () { + var self = this; + self.ready = catalogReady; + self.$nextTick(function () { self.observeIcons(); }); + }, + + watch: { + cat: function () { + var self = this; + self.$nextTick(function () { self.observeIcons(); }); + }, + query: function () { + var self = this; + self.$nextTick(function () { self.observeIcons(); }); + }, + queryInput: function (val) { + var self = this; + if (queryDebounceTimer) clearTimeout(queryDebounceTimer); + queryDebounceTimer = setTimeout(function () { + self.query = val; + }, 150); + }, + }, + + methods: { + setCat: function (id) { this.cat = id; }, + + observeIcons: function () { + setupIconObserver(this.$refs.grid); + }, + + doSpawn: function (entry) { + var self = this; + spawnFromEntry(entry) + .then(function () { + Blockbench.showQuickMessage("[OK] " + entry.name + " spawned", 1500); + }) + .catch(function (e) { + Blockbench.showQuickMessage("[X] " + e.message, 2500); + console.error("[IGP]", e); + }); + }, + }, + }, + }); + } + + BBPlugin.register("item_generator", { + title: "Item Generator", + author: "Speaway", + description: "Minecraft block & item generator with live vanilla textures from mcasset.cloud.", + icon: "fas.fa-cube", + version: "2.1.0", + min_version: "4.10.0", + variant: "desktop", + await_loading: true, + tags: ["Minecraft: Java Edition", "Minecraft: Bedrock Edition", "Generator"], + + onload: function () { + styleEl = document.createElement("style"); + styleEl.id = "igp_style"; + styleEl.textContent = CSS; + document.head.appendChild(styleEl); + + var cached = null; + try { cached = localStorage.getItem(LS_VERSION_KEY); } catch (e) { /* ignore */ } + if (cached && cached !== mcVersion) clearTextureCache(); + + return McAssetLoader.init().then(function (version) { + console.log("[IGP] Catalog ready -- MC " + version + + " (" + CATALOG.blocks.length + " blocks, " + CATALOG.items.length + " items)"); + createPanel(); + }).catch(function (err) { + console.error("[IGP] Init failed:", err); + catalogReady = true; + createPanel(); + Blockbench.showQuickMessage("IGP: catalog load failed -- search may be empty", 3000); + }); + }, + + onunload: function () { + if (iconObserver) { iconObserver.disconnect(); iconObserver = null; } + if (queryDebounceTimer) clearTimeout(queryDebounceTimer); + if (igpPanel) { igpPanel.delete(); igpPanel = null; } + if (styleEl) { styleEl.remove(); styleEl = null; } + }, + }); +})(); diff --git a/plugins/recordshots/recordshots.js b/plugins/recordshots/recordshots.js new file mode 100644 index 00000000..310d215e --- /dev/null +++ b/plugins/recordshots/recordshots.js @@ -0,0 +1,1670 @@ +/// +(function () { + 'use strict'; + + const PLUGIN_ID = 'recordshots'; + const PREFS_KEY = 'speaway_recordshots'; + const DEFAULT_ANGLE_ID = 'isometric_left'; + + let action = null; + let styleEl = null; + let modeBarBtn = null; + let modeBarObserver = null; + let modeBarLabelNode = null; + let cancelRequested = false; + let isRunning = false; + let escapeKeyHandler = null; + let modeSyncHandlers = []; + + const DEFAULT_PREFS = { + selected_angle_ids: [DEFAULT_ANGLE_ID], + output_root: '', + use_default_pictures_path: true, + variant_batch: false, + panel_mode: 'screenshot', + record_format: 'apng', + record_fps: 20, + record_animation: '', + record_bg_color: '#000000' + }; + + const RECORD_FORMATS = ['apng', 'gif', 'png_sequence', 'mp4']; + + // --- Native modules -------------------------------------------------------- + + function canUseAppFileSystem() { + return typeof require === 'function' || typeof requireNativeModule === 'function'; + } + + function getNativeModule(moduleName, options) { + let moduleRef = null; + if (typeof requireNativeModule === 'function') { + try { + moduleRef = options + ? requireNativeModule(moduleName, options) + : requireNativeModule(moduleName); + } catch (e) { /* ignore */ } + } + if (!moduleRef && typeof require === 'function') { + try { + moduleRef = require(moduleName); + } catch (e) { /* ignore */ } + } + return moduleRef; + } + + function getPathModule() { + if (typeof PathModule !== 'undefined') return PathModule; + return getNativeModule('path') || getNativeModule('node:path'); + } + + function getFsForScope(scopePath, message) { + return getNativeModule('fs', { + scope: scopePath, + message: message || 'Required to read/write screenshot and texture files.', + optional: false + }); + } + + function getPicturesDir() { + try { + if (typeof electron !== 'undefined' && electron.app && electron.app.getPath) { + return electron.app.getPath('pictures'); + } + } catch (e) { /* ignore */ } + try { + if (typeof require === 'function') { + let remote = require('@electron/remote'); + if (remote && remote.app) return remote.app.getPath('pictures'); + } + } catch (e) { /* ignore */ } + if (typeof SystemInfo !== 'undefined' && SystemInfo.home_directory) { + return getPathModule().join(SystemInfo.home_directory, 'Pictures'); + } + return ''; + } + + function getChildProcess() { + return getNativeModule('child_process', { + message: 'Required to encode MP4 recordings with FFmpeg.', + optional: false + }) || getNativeModule('child_process') || getNativeModule('node:child_process'); + } + + function colorToHex(value) { + if (!value) return '#000000'; + if (typeof value === 'string') { + return value.charAt(0) === '#' ? value : ('#' + value); + } + if (typeof value.toHexString === 'function') return value.toHexString(); + if (typeof value.toHex === 'function') return '#' + value.toHex(); + if (value.hex) return value.hex.charAt(0) === '#' ? value.hex : ('#' + value.hex); + return '#000000'; + } + + function spawnProcess(exe, args, stdioOpts) { + let cp = getChildProcess(); + if (!cp || typeof cp.spawn !== 'function') { + throw new Error('child_process is not available'); + } + let p = cp.spawn(exe, args, stdioOpts || { stdio: 'ignore' }); + p.promise = new Promise(function (resolve, reject) { + p.on('close', function (code) { resolve(code); }); + p.on('error', reject); + }); + return p; + } + + async function resolveFfmpegPath() { + let candidates = [ + localStorage.getItem('ffmpegPath'), + 'ffmpeg', + '/usr/local/bin/ffmpeg' + ].filter(Boolean); + for (let i = 0; i < candidates.length; i++) { + let exe = candidates[i]; + try { + let p = spawnProcess(exe, []); + await p.promise; + return exe; + } catch (e) { /* try next */ } + } + return null; + } + + function showFfmpegMissingDialog() { + Blockbench.showMessageBox({ + title: 'FFmpeg Required', + message: 'MP4 export needs FFmpeg on your system. Download it from https://ffmpeg.org/download.html and ensure it is on PATH, or set localStorage ffmpegPath (Scene Recorder’s path picker also works).', + icon: 'error' + }); + } + + function canvasToPngBuffer(canvas) { + return new Promise(function (resolve, reject) { + canvas.toBlob(function (blob) { + if (!blob) { + reject(new Error('Failed to encode frame PNG')); + return; + } + blob.arrayBuffer().then(function (ab) { + if (typeof Buffer !== 'undefined') resolve(Buffer.from(ab)); + else resolve(new Uint8Array(ab)); + }).catch(reject); + }, 'image/png'); + }); + } + + async function encodeMp4FromCanvases(frameCanvases, fps, outPath) { + let ffmpegPath = await resolveFfmpegPath(); + if (!ffmpegPath) throw new Error('FFmpeg not found'); + if (!frameCanvases || !frameCanvases.length) throw new Error('No frames to encode'); + + let buffers = []; + for (let i = 0; i < frameCanvases.length; i++) { + buffers.push(await canvasToPngBuffer(frameCanvases[i])); + } + + let args = [ + '-framerate', String(fps || 20), + '-i', '-', + '-c:v', 'libx264', + '-pix_fmt', 'yuv420p', + '-vf', 'scale=floor(iw/2)*2:floor(ih/2)*2', + '-an', + '-f', 'mp4', + '-y', outPath + ]; + let p = spawnProcess(ffmpegPath, args, { stdio: ['pipe', 'ignore', 'pipe'] }); + let errOut = ''; + if (p.stderr && typeof p.stderr.on === 'function') { + p.stderr.on('data', function (chunk) { errOut += String(chunk); }); + } + + return new Promise(function (resolve, reject) { + p.on('error', reject); + p.on('close', function (code) { + if (code === 0) resolve(outPath); + else { + let hint = errOut ? errOut.trim().slice(-300) : ''; + reject(new Error('FFmpeg failed (exit ' + code + ')' + (hint ? ': ' + hint : ''))); + } + }); + try { + for (let i = 0; i < buffers.length; i++) { + p.stdin.write(buffers[i]); + } + p.stdin.end(); + } catch (e) { + reject(e); + } + }); + } + + // --- Prefs / paths --------------------------------------------------------- + + function loadPrefs() { + try { + let raw = localStorage.getItem(PREFS_KEY); + if (!raw) { + // Migrate prefs from previous plugin id + raw = localStorage.getItem('speaway_batch_screenshot'); + } + if (!raw) return Object.assign({}, DEFAULT_PREFS); + let parsed = JSON.parse(raw); + return { + selected_angle_ids: Array.isArray(parsed.selected_angle_ids) && parsed.selected_angle_ids.length + ? parsed.selected_angle_ids.slice() + : DEFAULT_PREFS.selected_angle_ids.slice(), + output_root: typeof parsed.output_root === 'string' ? parsed.output_root : '', + use_default_pictures_path: parsed.use_default_pictures_path !== false, + variant_batch: parsed.variant_batch === true, + panel_mode: parsed.panel_mode === 'record' ? 'record' : 'screenshot', + record_format: RECORD_FORMATS.indexOf(parsed.record_format) >= 0 + ? parsed.record_format + : 'apng', + record_fps: (typeof parsed.record_fps === 'number' && parsed.record_fps > 0) + ? parsed.record_fps + : 20, + record_animation: typeof parsed.record_animation === 'string' ? parsed.record_animation : '', + record_bg_color: (typeof parsed.record_bg_color === 'string' && parsed.record_bg_color) + ? parsed.record_bg_color + : '#000000' + }; + } catch (e) { + return Object.assign({}, DEFAULT_PREFS); + } + } + + function savePrefs(prefs) { + try { + localStorage.setItem(PREFS_KEY, JSON.stringify({ + selected_angle_ids: prefs.selected_angle_ids || DEFAULT_PREFS.selected_angle_ids, + output_root: prefs.output_root || '', + use_default_pictures_path: prefs.use_default_pictures_path !== false, + variant_batch: prefs.variant_batch === true, + panel_mode: prefs.panel_mode === 'record' ? 'record' : 'screenshot', + record_format: prefs.record_format || 'apng', + record_fps: prefs.record_fps || 20, + record_animation: prefs.record_animation || '', + record_bg_color: prefs.record_bg_color || '#000000' + })); + } catch (e) { /* ignore */ } + } + + // --- Paths / names --------------------------------------------------------- + + function sanitizeName(name) { + return String(name || 'model') + .replace(/\.geo\.json$/i, '') + .replace(/\.bbmodel$/i, '') + .replace(/\.geo$/i, '') + .replace(/[/\\:*?"<>|]+/g, '_') + .replace(/\s+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') || 'model'; + } + + function getModelName() { + if (!Project) return 'model'; + return sanitizeName(Project.name || 'model'); + } + + function getDefaultScreenshotsRoot() { + let pictures = getPicturesDir(); + if (!pictures) return ''; + return getPathModule().join(pictures, 'Blockbench', 'Screenshots'); + } + + function getOutputFolderForModel(prefs) { + let pathMod = getPathModule(); + let root; + if (prefs.use_default_pictures_path || !prefs.output_root) { + root = getDefaultScreenshotsRoot(); + } else { + root = prefs.output_root; + } + if (!root) return ''; + return pathMod.join(root, getModelName()); + } + + function ensureDir(fsMod, dirPath) { + if (!fsMod.existsSync(dirPath)) { + fsMod.mkdirSync(dirPath, { recursive: true }); + } + } + + function writeDataUrlPng(fsMod, pathMod, dataUrl, folder, filename) { + return writeDataUrlFile(fsMod, pathMod, dataUrl, folder, filename); + } + + function writeDataUrlFile(fsMod, pathMod, dataUrl, folder, filename) { + ensureDir(fsMod, folder); + let fullPath = pathMod.join(folder, filename); + if (typeof dataUrl !== 'string') { + throw new Error('Invalid image data'); + } + let comma = dataUrl.indexOf(','); + if (comma === -1) throw new Error('Invalid image data'); + fsMod.writeFileSync(fullPath, dataUrl.slice(comma + 1), 'base64'); + return fullPath; + } + + function resolveOutputPaths(prefs) { + let pathMod = getPathModule(); + let permissionRoot; + let outFolder; + if (prefs.use_default_pictures_path || !prefs.output_root) { + permissionRoot = getPicturesDir(); + if (!permissionRoot) return { error: 'pictures' }; + outFolder = pathMod.join(permissionRoot, 'Blockbench', 'Screenshots', getModelName()); + } else { + permissionRoot = prefs.output_root; + outFolder = pathMod.join(permissionRoot, getModelName()); + } + let fsMod = getFsForScope(permissionRoot, 'Required to save batch screenshots under your Pictures or chosen folder.'); + if (!fsMod || !pathMod) return { error: 'fs' }; + return { pathMod: pathMod, permissionRoot: permissionRoot, outFolder: outFolder, fsMod: fsMod }; + } + + function projectHasAnimations() { + return typeof Animation !== 'undefined' && Animation.all && Animation.all.length > 0; + } + + function getProjectAnimations() { + if (!projectHasAnimations()) return []; + return Animation.all.slice(); + } + + // --- Angles ---------------------------------------------------------------- + + function getAllAnglePresets() { + let list = []; + if (typeof DefaultCameraPresets !== 'undefined' && Array.isArray(DefaultCameraPresets)) { + DefaultCameraPresets.forEach(function (preset) { + if (!preset || typeof preset !== 'object') return; + if (typeof Condition === 'function' && preset.condition && !Condition(preset.condition)) return; + list.push({ + id: preset.id || sanitizeName(preset.name), + name: typeof tl === 'function' ? tl(preset.name) : preset.name, + preset: preset, + custom: false + }); + }); + } + try { + let raw = localStorage.getItem('camera_presets'); + let custom = raw ? JSON.parse(raw) : []; + if (Array.isArray(custom)) { + custom.forEach(function (preset, i) { + if (!preset || typeof preset !== 'object') return; + list.push({ + id: 'custom_' + i, + name: preset.name || ('Custom ' + (i + 1)), + preset: preset, + custom: true + }); + }); + } + } catch (e) { /* ignore */ } + return list; + } + + function resolveSelectedPresets(selectedIds) { + let all = getAllAnglePresets(); + let byId = {}; + all.forEach(function (a) { byId[a.id] = a; }); + let result = []; + (selectedIds || []).forEach(function (id) { + if (byId[id]) result.push(byId[id]); + }); + if (!result.length) { + let fallback = all.find(function (a) { return a.id === DEFAULT_ANGLE_ID; }) || all[0]; + if (fallback) result.push(fallback); + } + return result; + } + + // --- Textures (panel Texture.all) ------------------------------------------ + + function getPanelTextures() { + if (typeof Texture === 'undefined' || !Texture.all) return []; + return Texture.all.slice(); + } + + function textureFileLabel(tex) { + let name = (tex && (tex.name || tex.id)) || 'texture'; + return sanitizeName(String(name).replace(/\.[^.]+$/, '')); + } + + function backupFaceTextures() { + let backup = []; + let elements = (typeof Outliner !== 'undefined' && Outliner.elements) ? Outliner.elements : []; + elements.forEach(function (el) { + if (!el || !el.faces) return; + let faces = {}; + Object.keys(el.faces).forEach(function (key) { + let face = el.faces[key]; + if (!face) return; + faces[key] = face.texture === undefined ? null : face.texture; + }); + backup.push({ uuid: el.uuid, faces: faces }); + }); + return backup; + } + + function applyTextureToModel(tex) { + if (!tex) return; + let elements = (typeof Outliner !== 'undefined' && Outliner.elements) ? Outliner.elements : []; + elements.forEach(function (el) { + if (!el) return; + if (typeof el.applyTexture === 'function') { + try { + el.applyTexture(tex, true); + return; + } catch (e) { /* fall through */ } + } + if (!el.faces) return; + Object.keys(el.faces).forEach(function (key) { + if (el.faces[key]) el.faces[key].texture = tex.uuid; + }); + }); + if (typeof Canvas !== 'undefined') { + if (typeof Canvas.updateAllFaces === 'function') Canvas.updateAllFaces(); + else if (typeof Canvas.updateView === 'function') { + Canvas.updateView({ + elements: elements, + element_aspects: { geometry: true, uv: true }, + selection: false + }); + } + } + } + + function restoreFaceTextures(backup) { + if (!backup || !backup.length) return; + let byUuid = {}; + let elements = (typeof Outliner !== 'undefined' && Outliner.elements) ? Outliner.elements : []; + elements.forEach(function (el) { + if (el && el.uuid) byUuid[el.uuid] = el; + }); + backup.forEach(function (entry) { + let el = byUuid[entry.uuid]; + if (!el || !el.faces || !entry.faces) return; + Object.keys(entry.faces).forEach(function (key) { + if (el.faces[key]) el.faces[key].texture = entry.faces[key]; + }); + }); + if (typeof Canvas !== 'undefined') { + if (typeof Canvas.updateAllFaces === 'function') Canvas.updateAllFaces(); + else if (typeof Canvas.updateView === 'function') { + Canvas.updateView({ + elements: elements, + element_aspects: { geometry: true, uv: true }, + selection: false + }); + } + } + } + + // --- Capture --------------------------------------------------------------- + + function nextFrames(count) { + count = count || 2; + return new Promise(function (resolve) { + function step(n) { + if (n <= 0) { + resolve(); + return; + } + requestAnimationFrame(function () { step(n - 1); }); + } + step(count); + }); + } + + /** + * After an angle preset is loaded, re-center and zoom so the full model fits + * using world bounds (works for ortho/isometric and any model size). + */ + function getVisibleModelBounds() { + let box = new THREE.Box3(); + let expand = function () { + let elements = (typeof Outliner !== 'undefined' && Outliner.elements) ? Outliner.elements : []; + elements.forEach(function (el) { + if (!el || el.visibility === false) return; + if (el.mesh && el.mesh.geometry) { + box.expandByObject(el.mesh); + } + }); + }; + if (typeof Canvas !== 'undefined' && typeof Canvas.withoutGizmos === 'function') { + Canvas.withoutGizmos(expand); + } else { + expand(); + } + return box; + } + + function frameModelToFit() { + return new Promise(function (resolve) { + try { + let preview = Preview.selected; + if (!preview || !preview.camera || !preview.controls) { + resolve(); + return; + } + + let box = getVisibleModelBounds(); + if (!box || box.isEmpty()) { + resolve(); + return; + } + + let center = new THREE.Vector3(); + let size = new THREE.Vector3(); + box.getCenter(center); + box.getSize(size); + + let maxDim = Math.max(size.x, size.y, size.z, 1); + let padding = 1.35; + let neededHalf = (maxDim * padding) / 2; + + let offset = preview.camera.position.clone().sub(preview.controls.target); + if (offset.lengthSq() < 1e-8) { + offset.set(1, 0.8, 1); + } + + preview.controls.target.copy(center); + if (preview.side_view_target && preview.side_view_target.copy) { + preview.side_view_target.copy(center); + } + + if (preview.isOrtho) { + // At zoom=1, half-extent ≈ preview.size/80 (see Preview.resize) + let halfW = Math.max(preview.width, 1) / 80; + let halfH = Math.max(preview.height, 1) / 80; + let zoom = Math.min(halfW, halfH) / neededHalf; + preview.camera.zoom = Math.max(zoom, 0.001); + preview.camera.updateProjectionMatrix(); + offset.setLength(Math.max(maxDim * 2, 64)); + preview.camera.position.copy(center).add(offset); + } else { + let fov = preview.camera.fov || 45; + let halfFovRad = (fov * 0.5) * Math.PI / 180; + let dist = neededHalf / Math.tan(halfFovRad); + offset.setLength(Math.max(dist, maxDim, 16)); + preview.camera.position.copy(center).add(offset); + preview.camera.lookAt(center); + } + + if (typeof preview.controls.update === 'function') { + preview.controls.update(); + } + if (typeof preview.render === 'function') { + preview.render(); + } + } catch (e) { + console.warn('[Recordshots] frameModelToFit failed', e); + } + resolve(); + }); + } + + function takeScreenshotDataUrl() { + return new Promise(function (resolve, reject) { + let preview = Preview.selected; + if (!preview || typeof preview.screenshot !== 'function') { + reject(new Error('No preview available')); + return; + } + preview.screenshot({}, function (dataUrl) { + if (dataUrl) resolve(dataUrl); + else reject(new Error('Screenshot failed')); + }); + }); + } + + async function runBatch(options) { + if (isRunning) { + Blockbench.showQuickMessage('Recordshots already running', 2000); + return; + } + if (!Project) { + Blockbench.showQuickMessage('Open a model first', 2000); + return; + } + if (!canUseAppFileSystem()) { + Blockbench.showMessageBox({ + title: 'Desktop Only', + message: 'Recordshots requires the Blockbench desktop app to save files.', + icon: 'error' + }); + return; + } + + let prefs = loadPrefs(); + prefs.selected_angle_ids = options.selected_angle_ids; + prefs.use_default_pictures_path = options.use_default_pictures_path; + prefs.output_root = options.output_root || ''; + prefs.variant_batch = options.variant_batch === true; + prefs.panel_mode = 'screenshot'; + savePrefs(prefs); + + let angles = resolveSelectedPresets(prefs.selected_angle_ids); + if (!angles.length) { + Blockbench.showQuickMessage('Select at least one camera angle', 2500); + return; + } + + let variantBatch = prefs.variant_batch === true; + let panelTextures = variantBatch ? getPanelTextures() : []; + // Default WYSIWYG: one pass of the model as currently displayed (no texture swap) + let passes = (variantBatch && panelTextures.length) + ? panelTextures.map(function (tex) { + return { texture: tex, label: textureFileLabel(tex) }; + }) + : [{ texture: null, label: getModelName() }]; + + let paths = resolveOutputPaths(prefs); + if (paths.error === 'pictures') { + Blockbench.showMessageBox({ + title: 'Output Folder', + message: 'Could not find the Pictures folder. Choose a custom output folder instead.', + icon: 'folder' + }); + return; + } + if (paths.error) { + Blockbench.showMessageBox({ + title: 'Permission Denied', + message: 'File system access was denied. Allow access to save screenshots.', + icon: 'error' + }); + return; + } + let pathMod = paths.pathMod; + let outFolder = paths.outFolder; + let fsMod = paths.fsMod; + + let total = passes.length * angles.length; + let done = 0; + let saved = 0; + cancelRequested = false; + isRunning = true; + + let faceBackup = (variantBatch && panelTextures.length) ? backupFaceTextures() : null; + let originalAngle = null; + try { + if (Preview.selected && Preview.selected.camera) { + originalAngle = { + position: Preview.selected.camera.position.toArray(), + target: Preview.selected.controls.target.toArray(), + projection: Preview.selected.isOrtho ? 'orthographic' : 'perspective', + zoom: Preview.selected.isOrtho ? Preview.selected.camera.zoom : undefined + }; + } + } catch (e) { /* ignore */ } + + attachEscapeCancel(); + Blockbench.setStatusBarText('Recordshots: capturing…'); + Blockbench.setProgress(0); + + try { + ensureDir(fsMod, outFolder); + + for (let pi = 0; pi < passes.length; pi++) { + if (cancelRequested) break; + let pass = passes[pi]; + + if (pass.texture) { + Blockbench.setStatusBarText('Applying texture: ' + (pass.texture.name || pass.label)); + applyTextureToModel(pass.texture); + await nextFrames(3); + } + + for (let ai = 0; ai < angles.length; ai++) { + if (cancelRequested) break; + let angle = angles[ai]; + Preview.selected.loadAnglePreset(angle.preset); + await frameModelToFit(); + await nextFrames(2); + + Blockbench.setStatusBarText( + 'Capturing ' + (done + 1) + '/' + total + ': ' + pass.label + ' @ ' + angle.name + ); + + let shot = await takeScreenshotDataUrl(); + let fileName = pass.label + '_' + sanitizeName(angle.id) + '.png'; + writeDataUrlPng(fsMod, pathMod, shot, outFolder, fileName); + saved++; + done++; + Blockbench.setProgress(done / total); + } + } + } catch (err) { + console.error('[Recordshots]', err); + Blockbench.showMessageBox({ + title: 'Recordshots Failed', + message: (err && err.message) ? err.message : String(err), + icon: 'error' + }); + } finally { + if (faceBackup) { + restoreFaceTextures(faceBackup); + await nextFrames(2); + } + if (originalAngle && Preview.selected) { + try { + Preview.selected.loadAnglePreset(originalAngle); + } catch (e) { /* ignore */ } + } + detachEscapeCancel(); + isRunning = false; + cancelRequested = false; + Blockbench.setProgress(); + Blockbench.setStatusBarText(); + if (saved > 0) { + Blockbench.showQuickMessage('Saved ' + saved + ' screenshot(s) to ' + outFolder, 4000); + } else if (!cancelRequested) { + Blockbench.showQuickMessage('No screenshots saved', 2500); + } else { + Blockbench.showQuickMessage('Batch cancelled (' + saved + ' saved)', 3000); + } + } + } + + function selectAnimation(anim) { + if (!anim) return; + if (typeof anim.select === 'function') { + anim.select(); + } else if (typeof Animation !== 'undefined' && typeof Animation.select === 'function') { + Animation.select(anim); + } + } + + function ensureAnimateMode() { + let previousId = (typeof Modes !== 'undefined' && Modes.selected) ? Modes.selected.id : null; + if (typeof Modes !== 'undefined' && Modes.options && Modes.options.animate) { + if (!Modes.selected || Modes.selected.id !== 'animate') { + try { + Modes.options.animate.select(); + } catch (e) { /* ignore */ } + } + } + return previousId; + } + + function restoreMode(modeId) { + if (!modeId || typeof Modes === 'undefined' || !Modes.options) return; + if (Modes.selected && Modes.selected.id === modeId) return; + let mode = Modes.options[modeId]; + if (mode && typeof mode.select === 'function') { + try { + mode.select(); + } catch (e) { /* ignore */ } + } + } + + function cancelGifRecorderUi() { + let tools = document.querySelectorAll('#gif_recording_controls .tool'); + if (tools.length) { + tools[tools.length - 1].click(); + return; + } + let frame = document.getElementById('gif_recording_frame'); + if (frame) frame.remove(); + } + + function autoStartGifRecorder() { + return new Promise(function (resolve, reject) { + let attempts = 0; + function tryClick() { + let btn = document.querySelector('#gif_recording_frame .gif_record_button'); + if (btn) { + btn.click(); + resolve(); + return; + } + attempts++; + if (attempts > 40) { + reject(new Error('Could not start animation recorder')); + return; + } + setTimeout(tryClick, 50); + } + tryClick(); + }); + } + + function recordAnimationClip(options) { + return new Promise(function (resolve, reject) { + if (typeof Screencam === 'undefined' || typeof Screencam.createGif !== 'function') { + reject(new Error('Screencam.createGif is not available in this Blockbench version')); + return; + } + + let settled = false; + let exportPatched = false; + let originalExport = null; + let mp4FormatPatched = false; + let previousMp4Format = undefined; + let hadMp4Format = false; + + function restoreMp4Format() { + if (!mp4FormatPatched || typeof ScreencamGIFFormats === 'undefined') return; + mp4FormatPatched = false; + if (hadMp4Format) { + ScreencamGIFFormats.mp4 = previousMp4Format; + } else { + delete ScreencamGIFFormats.mp4; + } + } + + function finish(result, err) { + if (settled) return; + settled = true; + if (exportPatched && originalExport) { + Blockbench.export = originalExport; + exportPatched = false; + } + restoreMp4Format(); + if (err) reject(err); + else resolve(result); + } + + if (options.format === 'png_sequence' && typeof Blockbench.export === 'function') { + originalExport = Blockbench.export; + exportPatched = true; + Blockbench.export = function (exportOpts) { + if (exportOpts && exportOpts.savetype === 'zip' && exportOpts.content) { + finish({ kind: 'zip', blob: exportOpts.content }); + return; + } + return originalExport.apply(this, arguments); + }; + } + + if (options.format === 'mp4') { + if (typeof ScreencamGIFFormats === 'undefined') { + finish(null, new Error('ScreencamGIFFormats is not available')); + return; + } + if (!options.mp4OutPath) { + finish(null, new Error('MP4 output path missing')); + return; + } + hadMp4Format = Object.prototype.hasOwnProperty.call(ScreencamGIFFormats, 'mp4'); + previousMp4Format = ScreencamGIFFormats.mp4; + mp4FormatPatched = true; + ScreencamGIFFormats.mp4 = { + name: 'MP4 Video', + process: async function (vars, gifOptions) { + try { + await encodeMp4FromCanvases( + vars.frame_canvases, + gifOptions.fps || options.fps || 20, + options.mp4OutPath + ); + finish({ kind: 'file', path: options.mp4OutPath }); + } catch (encErr) { + finish(null, encErr); + } + } + }; + } + + if (typeof Screencam.gif_crop === 'object') { + Screencam.gif_crop.top = 0; + Screencam.gif_crop.left = 0; + Screencam.gif_crop.right = 0; + Screencam.gif_crop.bottom = 0; + } + + let existingFrame = document.getElementById('gif_recording_frame'); + if (existingFrame) existingFrame.remove(); + + let gifOpts = { + format: options.format || 'apng', + fps: options.fps || 20, + length_mode: 'animation', + play: true, + silent: true, + show_gizmos: false + }; + if (options.format === 'mp4') { + gifOpts.background = options.background || '#000000'; + } + + Screencam.createGif(gifOpts, function (dataUrl) { + finish({ kind: 'dataUrl', dataUrl: dataUrl }); + }); + + autoStartGifRecorder().catch(function (err) { + cancelGifRecorderUi(); + finish(null, err); + }); + + let cancelWatch = setInterval(function () { + if (settled) { + clearInterval(cancelWatch); + return; + } + if (cancelRequested) { + clearInterval(cancelWatch); + cancelGifRecorderUi(); + finish(null, new Error('cancelled')); + } + }, 100); + }); + } + + function writeBlobFile(fsMod, pathMod, blob, folder, filename) { + return new Promise(function (resolve, reject) { + ensureDir(fsMod, folder); + let fullPath = pathMod.join(folder, filename); + let reader = new FileReader(); + reader.onload = function () { + try { + let result = reader.result; + let comma = typeof result === 'string' ? result.indexOf(',') : -1; + let base64 = comma >= 0 ? result.slice(comma + 1) : result; + fsMod.writeFileSync(fullPath, base64, 'base64'); + resolve(fullPath); + } catch (e) { + reject(e); + } + }; + reader.onerror = function () { + reject(new Error('Failed to read zip blob')); + }; + reader.readAsDataURL(blob); + }); + } + + function extensionForRecordFormat(format) { + if (format === 'gif') return '.gif'; + if (format === 'png_sequence') return '.zip'; + if (format === 'mp4') return '.mp4'; + return '.apng'; + } + + async function runAnimationRecord(options) { + if (isRunning) { + Blockbench.showQuickMessage('Recordshots already running', 2000); + return; + } + if (!Project) { + Blockbench.showQuickMessage('Open a model first', 2000); + return; + } + if (!canUseAppFileSystem()) { + Blockbench.showMessageBox({ + title: 'Desktop Only', + message: 'Recordshots requires the Blockbench desktop app to save files.', + icon: 'error' + }); + return; + } + if (!projectHasAnimations()) { + Blockbench.showQuickMessage('No animations in this project', 2500); + return; + } + + let prefs = loadPrefs(); + prefs.selected_angle_ids = options.selected_angle_ids; + prefs.use_default_pictures_path = options.use_default_pictures_path; + prefs.output_root = options.output_root || ''; + prefs.panel_mode = 'record'; + prefs.record_format = options.record_format || 'apng'; + prefs.record_fps = options.record_fps || 20; + prefs.record_animation = options.record_animation || ''; + prefs.record_bg_color = colorToHex(options.record_bg_color || prefs.record_bg_color || '#000000'); + savePrefs(prefs); + + let angles = resolveSelectedPresets(prefs.selected_angle_ids); + if (!angles.length) { + Blockbench.showQuickMessage('Select at least one camera angle', 2500); + return; + } + + if (prefs.record_format === 'mp4') { + let ffmpegOk = await resolveFfmpegPath(); + if (!ffmpegOk) { + showFfmpegMissingDialog(); + return; + } + } + + let allAnims = getProjectAnimations(); + let anims; + if (options.record_animation === '__all__') { + anims = allAnims.slice(); + } else { + let match = allAnims.find(function (a) { + return a.uuid === options.record_animation || a.name === options.record_animation; + }); + anims = match ? [match] : (Animation.selected ? [Animation.selected] : allAnims.slice(0, 1)); + } + if (!anims.length) { + Blockbench.showQuickMessage('Select an animation to record', 2500); + return; + } + + let paths = resolveOutputPaths(prefs); + if (paths.error === 'pictures') { + Blockbench.showMessageBox({ + title: 'Output Folder', + message: 'Could not find the Pictures folder. Choose a custom output folder instead.', + icon: 'folder' + }); + return; + } + if (paths.error) { + Blockbench.showMessageBox({ + title: 'Permission Denied', + message: 'File system access was denied. Allow access to save recordings.', + icon: 'error' + }); + return; + } + + let pathMod = paths.pathMod; + let outFolder = paths.outFolder; + let fsMod = paths.fsMod; + let format = prefs.record_format; + let fps = prefs.record_fps; + let bgColor = colorToHex(prefs.record_bg_color || '#000000'); + let ext = extensionForRecordFormat(format); + + let total = anims.length * angles.length; + let done = 0; + let saved = 0; + cancelRequested = false; + isRunning = true; + + let originalAngle = null; + let previousModeId = ensureAnimateMode(); + await nextFrames(3); + + try { + if (Preview.selected && Preview.selected.camera) { + originalAngle = { + position: Preview.selected.camera.position.toArray(), + target: Preview.selected.controls.target.toArray(), + projection: Preview.selected.isOrtho ? 'orthographic' : 'perspective', + zoom: Preview.selected.isOrtho ? Preview.selected.camera.zoom : undefined + }; + } + } catch (e) { /* ignore */ } + + attachEscapeCancel(); + Blockbench.setStatusBarText('Recordshots: recording…'); + Blockbench.setProgress(0); + + try { + ensureDir(fsMod, outFolder); + + for (let ani = 0; ani < anims.length; ani++) { + if (cancelRequested) break; + let anim = anims[ani]; + selectAnimation(anim); + await nextFrames(2); + + let animLabel = sanitizeName(anim.name || ('anim_' + (ani + 1))); + + for (let ai = 0; ai < angles.length; ai++) { + if (cancelRequested) break; + let angle = angles[ai]; + Preview.selected.loadAnglePreset(angle.preset); + await frameModelToFit(); + await nextFrames(2); + + Blockbench.setStatusBarText( + 'Recording ' + (done + 1) + '/' + total + ': ' + animLabel + ' @ ' + angle.name + ); + + try { + let fileName = animLabel + '_' + sanitizeName(angle.id) + ext; + let clipOpts = { + format: format, + fps: fps + }; + if (format === 'mp4') { + clipOpts.background = bgColor; + clipOpts.mp4OutPath = pathMod.join(outFolder, fileName); + } + let result = await recordAnimationClip(clipOpts); + if (result.kind === 'file' && result.path) { + // Already written by FFmpeg + } else if (result.kind === 'zip' && result.blob) { + await writeBlobFile(fsMod, pathMod, result.blob, outFolder, fileName); + } else if (result.dataUrl) { + writeDataUrlFile(fsMod, pathMod, result.dataUrl, outFolder, fileName); + } else { + throw new Error('Recorder returned no data'); + } + saved++; + } catch (recErr) { + if (recErr && recErr.message === 'cancelled') break; + throw recErr; + } + + done++; + Blockbench.setProgress(done / total); + await nextFrames(2); + } + } + } catch (err) { + console.error('[Recordshots Record]', err); + Blockbench.showMessageBox({ + title: 'Recordshots Failed', + message: (err && err.message) ? err.message : String(err), + icon: 'error' + }); + } finally { + cancelGifRecorderUi(); + if (originalAngle && Preview.selected) { + try { + Preview.selected.loadAnglePreset(originalAngle); + } catch (e) { /* ignore */ } + } + restoreMode(previousModeId); + detachEscapeCancel(); + isRunning = false; + let wasCancelled = cancelRequested; + cancelRequested = false; + Blockbench.setProgress(); + Blockbench.setStatusBarText(); + if (saved > 0) { + Blockbench.showQuickMessage('Saved ' + saved + ' recording(s) to ' + outFolder, 4000); + } else if (wasCancelled) { + Blockbench.showQuickMessage('Record cancelled', 2500); + } else { + Blockbench.showQuickMessage('No recordings saved', 2500); + } + } + } + + function attachEscapeCancel() { + detachEscapeCancel(); + escapeKeyHandler = Blockbench.on('press_key', function (context) { + if (!isRunning) return; + if (context.event && (context.event.key === 'Escape' || context.event.which === 27)) { + cancelRequested = true; + Blockbench.setStatusBarText('Cancelling batch…'); + if (typeof context.capture === 'function') context.capture(); + } + }); + } + + function detachEscapeCancel() { + if (escapeKeyHandler && typeof escapeKeyHandler.delete === 'function') { + escapeKeyHandler.delete(); + } + escapeKeyHandler = null; + } + + // --- Dialog ---------------------------------------------------------------- + + function buildAngleFormFields(prefs) { + let fields = {}; + let angles = getAllAnglePresets(); + let selected = {}; + (prefs.selected_angle_ids || []).forEach(function (id) { selected[id] = true; }); + + fields.angles_help = { + type: 'info', + text: 'Camera angles (default: Isometric Left 2:1). Uses the same capture as Screenshot Model (Ctrl+P).' + }; + fields.angle_buttons = { + type: 'buttons', + buttons: ['Select All', 'Select None'], + click: function (index) { + if (!Dialog.open) return; + let form = {}; + angles.forEach(function (a) { + form['angle_' + a.id] = index === 0; + }); + Dialog.open.setFormValues(form); + } + }; + + angles.forEach(function (a) { + fields['angle_' + a.id] = { + label: a.name + (a.id === DEFAULT_ANGLE_ID ? ' (default)' : ''), + type: 'checkbox', + value: !!selected[a.id] || (prefs.selected_angle_ids.length === 0 && a.id === DEFAULT_ANGLE_ID) + }; + }); + + return { fields: fields, angles: angles }; + } + + function isAnimateMode() { + return typeof Modes !== 'undefined' && Modes.selected && Modes.selected.id === 'animate'; + } + + function isAnimateRecordContext() { + return isAnimateMode() && projectHasAnimations(); + } + + function openContextualDialog() { + if (isAnimateRecordContext()) openRecordDialog(); + else openScreenshotDialog(); + } + + function buildFolderFormFields(prefs) { + let defaultRoot = getDefaultScreenshotsRoot(); + let previewOut = getOutputFolderForModel(prefs) || '(set output folder)'; + return { + _div_folder: '_', + use_default_pictures_path: { + label: 'Use Pictures/Blockbench/Screenshots', + type: 'checkbox', + value: prefs.use_default_pictures_path !== false, + description: 'Default: Pictures/Blockbench/Screenshots/{ModelName}/' + }, + output_root: { + label: 'Custom screenshots root', + type: 'folder', + value: prefs.output_root || defaultRoot || '', + condition: function (formResult) { + return !formResult.use_default_pictures_path; + }, + description: 'Parent folder; a subfolder named after the model is created automatically' + }, + output_preview: { + type: 'info', + text: 'Saves into: ' + previewOut + } + }; + } + + function selectedAngleIdsFromForm(formData, angles) { + let selectedIds = []; + angles.forEach(function (a) { + if (formData['angle_' + a.id]) selectedIds.push(a.id); + }); + return selectedIds; + } + + function openScreenshotDialog() { + if (!Project) { + Blockbench.showQuickMessage('Open a model first', 2000); + return; + } + if (isRunning) { + Blockbench.showQuickMessage('Recordshots already running — press Escape to cancel', 3000); + return; + } + + let prefs = loadPrefs(); + let angleBuild = buildAngleFormFields(prefs); + let texCount = getPanelTextures().length; + + let form = Object.assign({}, angleBuild.fields, buildFolderFormFields(prefs), { + _div2: '_', + variant_batch: { + label: 'Capture each Textures panel entry as a variant', + type: 'checkbox', + value: prefs.variant_batch === true, + description: 'Off (default): one photo of the model as shown. On: apply each panel texture to the whole model and capture separately (block-pack variants).' + }, + texture_info: { + type: 'info', + text: 'By default captures exactly what you see (including multi-texture / animated models as a single still). Escape cancels a running batch.' + + (texCount ? (' Textures panel currently has ' + texCount + ' texture(s).') : '') + } + }); + + function collectScreenshotPrefs(formData) { + let selectedIds = selectedAngleIdsFromForm(formData, angleBuild.angles); + if (!selectedIds.length) selectedIds = [DEFAULT_ANGLE_ID]; + let current = loadPrefs(); + return { + selected_angle_ids: selectedIds, + use_default_pictures_path: !!formData.use_default_pictures_path, + output_root: formData.output_root || '', + variant_batch: !!formData.variant_batch, + panel_mode: 'screenshot', + record_format: current.record_format, + record_fps: current.record_fps, + record_animation: current.record_animation, + record_bg_color: current.record_bg_color || '#000000' + }; + } + + let dialog = new Dialog({ + id: PLUGIN_ID + '_dialog', + title: 'Recordshots', + width: 520, + form: form, + buttons: ['Start', 'dialog.cancel'], + onConfirm: function (formData) { + let selectedIds = selectedAngleIdsFromForm(formData, angleBuild.angles); + if (!selectedIds.length) { + Blockbench.showQuickMessage('Select at least one angle', 2500); + return false; + } + let useDefault = !!formData.use_default_pictures_path; + let outputRoot = useDefault ? '' : (formData.output_root || ''); + savePrefs(collectScreenshotPrefs(formData)); + dialog.hide(); + setTimeout(function () { + runBatch({ + selected_angle_ids: selectedIds, + use_default_pictures_path: useDefault, + output_root: outputRoot, + variant_batch: !!formData.variant_batch + }); + }, 50); + return true; + }, + onFormChange: function (formData) { + savePrefs(collectScreenshotPrefs(formData)); + } + }); + dialog.show(); + } + + function openRecordDialog() { + if (!Project) { + Blockbench.showQuickMessage('Open a model first', 2000); + return; + } + if (isRunning) { + Blockbench.showQuickMessage('Recordshots already running — press Escape to cancel', 3000); + return; + } + if (!projectHasAnimations()) { + Blockbench.showQuickMessage('No animations in this project', 2500); + return; + } + + let prefs = loadPrefs(); + let angleBuild = buildAngleFormFields(prefs); + let animations = getProjectAnimations(); + let animOptions = { __all__: 'All animations' }; + animations.forEach(function (anim) { + let key = anim.uuid || anim.name; + animOptions[key] = anim.name || key; + }); + let defaultAnim = prefs.record_animation; + if (!defaultAnim || (defaultAnim !== '__all__' && !animOptions[defaultAnim])) { + defaultAnim = (Animation.selected && (Animation.selected.uuid || Animation.selected.name)) + || (animations[0] && (animations[0].uuid || animations[0].name)) + || '__all__'; + } + + let form = Object.assign({}, angleBuild.fields, { + _div_record: '_', + record_animation: { + label: 'Animation', + type: 'select', + value: defaultAnim, + options: animOptions + }, + record_format: { + label: 'Output format', + type: 'select', + value: prefs.record_format || 'apng', + options: { + apng: 'APNG (transparent)', + gif: 'GIF (transparent)', + png_sequence: 'PNG sequence (ZIP)', + mp4: 'MP4 (needs FFmpeg)' + } + }, + record_bg_color: { + label: 'MP4 background', + type: 'color', + value: prefs.record_bg_color || '#000000', + condition: function (formResult) { return formResult.record_format === 'mp4'; } + }, + record_fps: { + label: 'FPS', + type: 'number', + value: prefs.record_fps || 20, + min: 1, + max: 60 + }, + record_info: { + type: 'info', + text: 'APNG/GIF/PNG sequence use a transparent background. MP4 is opaque (pick a solid color) and requires FFmpeg on PATH or localStorage ffmpegPath. Saves as {animation}_{angle}. Escape cancels between jobs.' + } + }, buildFolderFormFields(prefs)); + + function collectRecordPrefs(formData) { + let selectedIds = selectedAngleIdsFromForm(formData, angleBuild.angles); + if (!selectedIds.length) selectedIds = [DEFAULT_ANGLE_ID]; + let current = loadPrefs(); + return { + selected_angle_ids: selectedIds, + use_default_pictures_path: !!formData.use_default_pictures_path, + output_root: formData.output_root || '', + variant_batch: current.variant_batch === true, + panel_mode: 'record', + record_format: formData.record_format || 'apng', + record_fps: (typeof formData.record_fps === 'number' && formData.record_fps > 0) ? formData.record_fps : 20, + record_animation: formData.record_animation || '', + record_bg_color: colorToHex(formData.record_bg_color || current.record_bg_color || '#000000') + }; + } + + let dialog = new Dialog({ + id: PLUGIN_ID + '_record_dialog', + title: 'Recordshots — Record', + width: 520, + form: form, + buttons: ['Start', 'dialog.cancel'], + onConfirm: function (formData) { + let selectedIds = selectedAngleIdsFromForm(formData, angleBuild.angles); + if (!selectedIds.length) { + Blockbench.showQuickMessage('Select at least one angle', 2500); + return false; + } + let useDefault = !!formData.use_default_pictures_path; + let outputRoot = useDefault ? '' : (formData.output_root || ''); + let nextPrefs = collectRecordPrefs(formData); + savePrefs(nextPrefs); + dialog.hide(); + setTimeout(function () { + runAnimationRecord({ + selected_angle_ids: selectedIds, + use_default_pictures_path: useDefault, + output_root: outputRoot, + record_format: nextPrefs.record_format, + record_fps: nextPrefs.record_fps, + record_animation: nextPrefs.record_animation, + record_bg_color: nextPrefs.record_bg_color + }); + }, 50); + return true; + }, + onFormChange: function (formData) { + savePrefs(collectRecordPrefs(formData)); + } + }); + dialog.show(); + } + + // --- Mode bar button ------------------------------------------------------- + + function updateModeBarButton() { + if (!modeBarBtn || !modeBarBtn.isConnected) return; + let record = isAnimateRecordContext(); + let nextMode = record ? 'record' : 'screenshot'; + if (modeBarBtn.getAttribute('data-mode') === nextMode && modeBarLabelNode && modeBarLabelNode.parentNode === modeBarBtn) { + // Still refresh action name if needed, but skip DOM churn + if (action) { + try { + let name = record ? 'Recordshots — Record' : 'Recordshots'; + if (typeof action.setName === 'function') action.setName(name); + else action.name = name; + } catch (e) { /* ignore */ } + } + return; + } + let label = record ? ' Record' : ' Screenshot'; + let title = record ? 'Recordshots — Record' : 'Recordshots'; + let iconName = record ? 'fiber_manual_record' : 'photo_camera'; + + modeBarBtn.title = title; + modeBarBtn.setAttribute('data-mode', nextMode); + + let oldIcon = modeBarBtn.querySelector('.icon, i, svg'); + let newIcon = Blockbench.getIconNode(iconName); + if (oldIcon && oldIcon.parentNode === modeBarBtn) { + modeBarBtn.replaceChild(newIcon, oldIcon); + } else { + modeBarBtn.insertBefore(newIcon, modeBarBtn.firstChild); + } + + if (modeBarLabelNode && modeBarLabelNode.parentNode === modeBarBtn) { + modeBarLabelNode.textContent = label; + } else { + Array.prototype.slice.call(modeBarBtn.childNodes).forEach(function (n) { + if (n.nodeType === 3) modeBarBtn.removeChild(n); + }); + modeBarLabelNode = document.createTextNode(label); + modeBarBtn.appendChild(modeBarLabelNode); + } + + if (action) { + try { + if (typeof action.setName === 'function') action.setName(title); + else action.name = title; + if (typeof action.setIcon === 'function') action.setIcon(iconName); + else action.icon = iconName; + } catch (e) { /* ignore */ } + } + } + + function createModeBarButton() { + if (modeBarBtn && modeBarBtn.isConnected) { + updateModeBarButton(); + return; + } + + let btn = document.createElement('li'); + btn.id = 'recordshots_mode_btn'; + btn.title = 'Recordshots'; + btn.setAttribute('role', 'button'); + btn.tabIndex = 0; + + let icon = Blockbench.getIconNode('photo_camera'); + btn.appendChild(icon); + modeBarLabelNode = document.createTextNode(' Screenshot'); + btn.appendChild(modeBarLabelNode); + + btn.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + openContextualDialog(); + }); + btn.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openContextualDialog(); + } + }); + + modeBarBtn = btn; + placeModeBarButton(); + updateModeBarButton(); + } + + function placeModeBarButton() { + if (!modeBarBtn) return; + let selector = document.getElementById('mode_selector'); + if (!selector) return; + if (modeBarBtn.parentNode === selector && selector.firstChild === modeBarBtn) return; + selector.insertBefore(modeBarBtn, selector.firstChild); + } + + function startModeBarWatcher() { + stopModeBarWatcher(); + createModeBarButton(); + let host = document.getElementById('main_toolbar') || document.body; + modeBarObserver = new MutationObserver(function () { + if (!document.getElementById('recordshots_mode_btn')) { + createModeBarButton(); + } else { + placeModeBarButton(); + } + }); + modeBarObserver.observe(host, { childList: true, subtree: true }); + } + + function stopModeBarWatcher() { + if (modeBarObserver) { + modeBarObserver.disconnect(); + modeBarObserver = null; + } + if (modeBarBtn) { + modeBarBtn.remove(); + modeBarBtn = null; + } + modeBarLabelNode = null; + } + + function bindModeSync() { + unbindModeSync(); + function sync() { + updateModeBarButton(); + } + ['select_mode', 'unselect_mode', 'select_project', 'load_project', 'close_project'].forEach(function (evt) { + try { + let h = Blockbench.on(evt, sync); + modeSyncHandlers.push(h); + } catch (e) { /* ignore */ } + }); + sync(); + } + + function unbindModeSync() { + modeSyncHandlers.forEach(function (h) { + if (h && typeof h.delete === 'function') h.delete(); + }); + modeSyncHandlers = []; + } + + // --- Plugin ---------------------------------------------------------------- + + const CSS = ` +#mode_selector > li#recordshots_mode_btn { + display: inline-block; + height: 30px; + overflow: hidden; + padding: 2px 10px; + margin-right: 10px; + border-radius: 5px; + font-size: 1.1em; + cursor: pointer; + color: var(--color-text); + vertical-align: top; + flex-shrink: 0; + white-space: nowrap; +} +#mode_selector > li#recordshots_mode_btn:hover { + color: var(--color-light); + background-color: var(--color-elevated); +} +#mode_selector > li#recordshots_mode_btn > .icon { + vertical-align: text-bottom; + color: var(--color-accent); +} +`; + + BBPlugin.register(PLUGIN_ID, { + title: 'Recordshots', + author: 'Speaway', + description: 'Screenshot and Record in one plugin: batch stills from camera angles, or in Animate mode export transparent APNG/GIF/PNG sequence or MP4 via FFmpeg. Optional per-texture variant mode for block packs.', + icon: 'photo_camera', + version: '1.3.1', + min_version: '4.10.0', + variant: 'desktop', + tags: ['Minecraft: Java Edition', 'Minecraft: Bedrock Edition', 'Utility'], + + onload: function () { + styleEl = document.createElement('style'); + styleEl.id = 'recordshots_style'; + styleEl.textContent = CSS; + document.head.appendChild(styleEl); + + action = new Action('recordshots_open', { + name: 'Recordshots', + description: 'Open Recordshots — Screenshot or Record (Animate mode)', + icon: 'photo_camera', + category: 'view', + condition: function () { return !!Project && !Format.image_editor; }, + click: function () { + openContextualDialog(); + } + }); + MenuBar.addAction(action, 'view'); + + startModeBarWatcher(); + bindModeSync(); + loadPrefs(); + }, + + onunload: function () { + cancelRequested = true; + detachEscapeCancel(); + unbindModeSync(); + stopModeBarWatcher(); + if (action) { + action.delete(); + action = null; + } + MenuBar.removeAction('view.recordshots_open'); + if (styleEl) { + styleEl.remove(); + styleEl = null; + } + isRunning = false; + } + }); +})(); diff --git a/plugins/text_generator/about.md b/plugins/text_generator/about.md new file mode 100644 index 00000000..cf8461df --- /dev/null +++ b/plugins/text_generator/about.md @@ -0,0 +1,13 @@ +**Text Generator** by [Speaway](https://speaway.com) turns any text into Minecraft-style 3D cube geometry. + +## Features +- Default Minecraft-style + curated block fonts + custom upload +- Size, depth (0 = paper-thin), spacing, alignment +- Colored outline border +- Bold, italic, underline, strikethrough +- Saveable presets +- Unicode and emoji support +- Double-click a Text group in the outliner to edit and regenerate + +## How to use +Go to **Tools -> Text Generator**, enter your text, adjust settings, and click Generate. diff --git a/plugins/text_generator/text_generator.js b/plugins/text_generator/text_generator.js new file mode 100644 index 00000000..128c2dc9 --- /dev/null +++ b/plugins/text_generator/text_generator.js @@ -0,0 +1,2373 @@ +(function() { + // ============================================ + // Text Generator + // Custom fonts, size, depth, colors, preview, presets + // ============================================ + + let action, dialog, aboutAction, presetMenu; + let previewGroup = null; + let previewCubes = []; + let pluginBrowserTabSyncHandler = null; + let pendingEditContext = null; + let outlinerDblClickHandler = null; + let outlinerDblClickPanel = null; + let groupBehaviorOverride = null; + let editTextGroupAction = null; + + const PLUGIN_ID = 'text_generator'; + const PLUGIN_NAME = 'Text Generator'; + const PLUGIN_AUTHOR = 'Speaway'; + const PLUGIN_VERSION = '1.0.0'; + const PLUGIN_DESCRIPTION = 'Create stunning 3D block-style text for Minecraft models in Blockbench. Default Minecraft-style letters plus curated block fonts (auto-loaded) or optional custom .ttf/.otf upload, adjustable size and depth, outlines, presets, and full Unicode/emoji support -- perfect for resource packs, maps, and Bedrock/Java block models.'; + const DEFAULT_TEXT_COLOR = 0x55FF55; + const TGP_TEXT_ROOT_PREFIX = 'Text: '; + const TGP_META_KEY = 'tgp_text_settings'; + const PLUGIN_ICON = 'text_fields'; + const PLUGIN_WEBSITE = 'https://speaway.com'; + + // ============================================ + // PRESET MANAGEMENT + // ============================================ + const DEFAULT_PRESETS = { + 'minecraft_classic': { + font: 'default', + fontSize: 16, + depth: 1, + outlineColor: '#000000', + outlineEnabled: false, + letterSpacing: 1, + lineSpacing: 3, + wordSpacing: 2, + alignment: 'left', + bold: false, + italic: false, + underline: false, + strikethrough: false + }, + 'pixel_art': { + font: 'karmatic_arcade', + fontSize: 12, + depth: 2, + outlineColor: '#2C3E50', + outlineEnabled: true, + letterSpacing: 0, + lineSpacing: 1, + wordSpacing: 1, + alignment: 'center', + bold: true, + italic: false, + underline: false, + strikethrough: false + }, + 'modern_3d': { + font: 'minecrafter', + fontSize: 24, + depth: 2, + outlineColor: '#FFFFFF', + outlineEnabled: true, + letterSpacing: 2, + lineSpacing: 4, + wordSpacing: 4, + alignment: 'center', + optimizeGeometry: true, + bold: true, + italic: false, + underline: true, + strikethrough: false + }, + 'retro_neon': { + font: 'karmatic_arcade', + fontSize: 20, + depth: 1, + outlineColor: '#FFFFFF', + outlineEnabled: true, + letterSpacing: 1, + lineSpacing: 2, + wordSpacing: 2, + alignment: 'center', + bold: false, + italic: false, + underline: false, + strikethrough: false + } + }; + + let userPresets = {}; + + function loadPresets() { + try { + let saved = localStorage.getItem('tgp_presets'); + if (!saved) { + saved = localStorage.getItem('amctg_presets'); + if (saved) localStorage.setItem('tgp_presets', saved); + } + if (saved) userPresets = JSON.parse(saved); + } catch(e) { userPresets = {}; } + } + + function savePresets() { + try { + localStorage.setItem('tgp_presets', JSON.stringify(userPresets)); + } catch(e) {} + } + + // ============================================ + // FONT MANAGEMENT + // ============================================ + const CURATED_FONT_ORDER = [ + 'default', 'upheaval', 'karmatic_arcade', + 'minecrafter', 'minecraft_pe', 'minercraftory', 'dimitri', 'inversionz', 'inversionz_italic' + ]; + + const CURATED_FONTS = { + default: { label: 'Default (Minecraft-style)', source: 'charmap' }, + upheaval: { label: 'Upheaval', source: 'cdnfonts', cssSlug: 'upheaval', family: 'Upheaval TT (BRK)' }, + karmatic_arcade: { label: 'Karmatic Arcade', source: 'cdnfonts', cssSlug: 'karmatic-arcade', family: 'Karmatic Arcade' }, + minecrafter: { label: 'MineCrafter', source: 'cdnfonts', cssSlug: 'minecrafter', family: 'minecrafter', fontWeight: '500' }, + minecraft_pe: { label: 'Minecraft PE', source: 'cdnfonts', cssSlug: 'minecraft-pe', family: 'MINECRAFT PE' }, + minercraftory: { label: 'Minercraftory', source: 'cdnfonts', cssSlug: 'minercraftory', family: 'Minercraftory' }, + dimitri: { label: 'Dimitri', source: 'cdnfonts', cssSlug: 'dimitri', family: 'Dimitri' }, + inversionz: { label: 'Inversionz', source: 'cdnfonts', cssSlug: 'inversionz', family: 'Inversionz', invertedCell: true }, + inversionz_italic: { label: 'Inversionz Italic', source: 'cdnfonts', cssSlug: 'inversionz', family: 'Inversionz', fontStyle: 'italic', invertedCell: true } + }; + + let loadedFonts = new Set(); + let customFonts = {}; + + function snapCoord(n) { + return Math.round(n); + } + + function resolveRenderDepth(depth) { + if (depth == null) return 1; + return Math.max(0, depth); + } + + function isDefaultFont(fontId) { + return fontId === 'default'; + } + + function isCustomFont(fontId) { + return !!customFonts[fontId]; + } + + function isInversionFont(fontId) { + const def = CURATED_FONTS[normalizeFontId(fontId)]; + return !!(def && def.invertedCell); + } + + function drawInversionGlyphCell(ctx, char, cellX, cellY, cellSize, fontStyle) { + ctx.save(); + ctx.font = fontStyle; + ctx.fillStyle = '#000000'; + ctx.fillRect(cellX, cellY, cellSize, cellSize); + ctx.globalCompositeOperation = 'destination-out'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(char, cellX + cellSize / 2, cellY + cellSize / 2); + ctx.restore(); + } + + function normalizeFontId(fontId) { + if (!fontId) return 'default'; + if (CURATED_FONTS[fontId] || customFonts[fontId]) return fontId; + return 'default'; + } + + function normalizePresetFontFields(preset) { + if (!preset) return preset; + preset.font = normalizeFontId(preset.font); + if (preset.font === 'inversionz' && preset.italic) { + preset.font = 'inversionz_italic'; + preset.italic = false; + } + return preset; + } + + function normalizeFormFontFields(formData) { + if (!formData) return formData; + formData.font = normalizeFontId(formData.font); + if (formData.font === 'inversionz' && formData.italic) { + formData.font = 'inversionz_italic'; + formData.italic = false; + } + return formData; + } + + function resolveFontFamily(fontId) { + fontId = normalizeFontId(fontId); + if (isDefaultFont(fontId)) return null; + if (CURATED_FONTS[fontId]) return CURATED_FONTS[fontId].family; + return fontId; + } + + function injectStylesheet(id, href) { + if (document.getElementById(id)) return Promise.resolve(true); + return new Promise((resolve) => { + const link = document.createElement('link'); + link.id = id; + link.rel = 'stylesheet'; + link.href = href; + link.onload = () => resolve(true); + link.onerror = () => resolve(false); + document.head.appendChild(link); + }); + } + + function loadCuratedFont(fontId) { + fontId = normalizeFontId(fontId); + if (isDefaultFont(fontId)) return Promise.resolve(true); + if (isCustomFont(fontId)) return Promise.resolve(true); + if (loadedFonts.has(fontId)) return Promise.resolve(true); + + const def = CURATED_FONTS[fontId]; + if (!def) return Promise.resolve(false); + + let cssPromise; + const cssKey = 'tgp_font_' + (def.cssSlug || fontId); + if (def.source === 'google') { + cssPromise = injectStylesheet(cssKey, 'https://fonts.googleapis.com/css2?family=' + encodeURIComponent(def.family.replace(/ /g, '+')) + '&display=swap'); + } else if (def.source === 'cdnfonts') { + cssPromise = injectStylesheet(cssKey, 'https://fonts.cdnfonts.com/css/' + def.cssSlug); + } else { + return Promise.resolve(false); + } + + return cssPromise.then((ok) => { + if (!ok) return false; + const style = def.fontStyle || 'normal'; + const weight = def.fontWeight || '400'; + const spec = style + ' ' + weight + ' 16px "' + def.family + '"'; + return document.fonts.load(spec).then(() => { + loadedFonts.add(fontId); + return true; + }).catch(() => false); + }); + } + + function loadCustomFont(file, callback) { + const reader = new FileReader(); + reader.onload = (e) => { + const fontData = e.target.result; + const fontFace = new FontFace('custom_' + Date.now(), fontData); + fontFace.load().then((loadedFace) => { + document.fonts.add(loadedFace); + const fontName = loadedFace.family; + customFonts[fontName] = fontData; + loadedFonts.add(fontName); + callback(fontName); + }).catch(() => { + Blockbench.showMessageBox({ + title: 'Font Error', + message: 'Failed to load custom font. Please try another file.', + buttons: ['OK'] + }); + }); + }; + reader.readAsArrayBuffer(file); + } + + function getFontOptions() { + const options = {}; + CURATED_FONT_ORDER.forEach(id => { + options[id] = CURATED_FONTS[id].label; + }); + Object.keys(customFonts).forEach(name => { + options[name] = name + ' (Custom)'; + }); + return options; + } + + function buildCanvasFontStyle(fontId, fontSize, bold, italic) { + const family = resolveFontFamily(fontId); + const def = CURATED_FONTS[normalizeFontId(fontId)]; + const useItalic = !!italic || (def && def.fontStyle === 'italic'); + const useBold = bold || (def && def.fontWeight && parseInt(def.fontWeight, 10) >= 500); + return (useItalic ? 'italic ' : '') + (useBold ? 'bold ' : '') + fontSize + 'px "' + family + '"'; + } + + function isInversionFontEntry(fontId) { + fontId = normalizeFontId(fontId); + return fontId === 'inversionz' || fontId === 'inversionz_italic'; + } + + function buildDefaultCharMap(depth, wordSpace) { + return { + a: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 3, 8, depth], + [3, 0, 0, 5, 8, depth], + [2, 3, 0, 3, 5, depth] + ] + }, + b: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 3, 8, depth], + [2, 0, 0, 3, 2, depth], + [2, 3, 0, 3, 5, depth], + [3, 3.5, 0, 4, 4.5, depth], + [3, 0, 0, 5, 3.5, depth], + [3, 4.5, 0, 5, 8, depth], + ] + }, + c: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 5, 8, depth], + [2, 0, 0, 5, 2, depth] + ] + }, + d: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 0, 0, 4, 2, depth], + [2, 6, 0, 4, 8, depth], + [3, 2, 0, 4, 6, depth], + [4, 1, 0, 5, 7, depth], + ] + }, + e: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 5, 8, depth], + [2, 3, 0, 4, 5, depth], + [2, 0, 0, 5, 2, depth] + ] + }, + f: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 5, 8, depth], + [2, 3, 0, 4, 5, depth] + ] + }, + g: { + width: 5, + cubes: [ + [0, 2, 0, 2, 8, depth], + [0, 0, 0, 5, 2, depth], + [3, 2, 0, 5, 4, depth], + [2, 6, 0, 5, 8, depth], + ] + }, + h: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [3, 0, 0, 5, 8, depth], + [2, 3, 0, 3, 5, depth] + ] + }, + i: { + width: 2, + cubes: [ + [0, 0, 0, 2, 8, depth] + ] + }, + j: { + width: 5, + cubes: [ + [0, 0, 0, 5, 2, depth], + [3, 2, 0, 5, 8, depth], + [1, 6, 0, 3, 8, depth] + ] + }, + k: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 2.5, 0, 3, 5.5, depth], + [3, 0, 0, 4, 8, depth], + [4, 5, 0, 5, 8, depth], + [4, 0, 0, 5, 3, depth], + ] + }, + l: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 0, 0, 5, 2, depth] + ] + }, + m: { + width: 7, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 3, 0, 3, 7, depth], + [3, 2, 0, 4, 6, depth], + [4, 3, 0, 5, 7, depth], + [5, 0, 0, 7, 8, depth], + ] + }, + n: { + width: 6, + cubes: [ + [0, 0, 0, 2, 8, depth], + [4, 0, 0, 6, 8, depth], + [2, 3, 0, 3, 6, depth], + [3, 2, 0, 4, 5, depth], + ] + }, + o: { + width: 6, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 4, 8, depth], + [4, 0, 0, 6, 8, depth], + [2, 0, 0, 4, 2, depth] + ] + }, + p: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [3, 3, 0, 5, 8, depth], + [2, 6, 0, 3, 8, depth], + [2, 3, 0, 3, 5, depth] + ] + }, + q: { + width: 5.5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 3, 8, depth], + [3, 0, 0, 5, 8, depth], + [2, 0, 0, 3, 2, depth], + [3.5, -0.5, 0, 5.5, 3.5, depth] + ] + }, + r: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 3, 8, depth], + [2, 3, 0, 3, 5, depth], + [3, 4, 0, 5, 8, depth], + [3, 0, 0, 5, 3, depth], + [3, 3, 0, 4, 4, depth], + ] + }, + s: { + width: 5, + cubes: [ + [0, 0, 0, 5, 2, depth], + [0, 3, 0, 5, 5, depth], + [0, 6, 0, 5, 8, depth], + [3, 2, 0, 5, 3, depth], + [0, 5, 0, 2, 6, depth], + ] + }, + "$": { + width: 5, + cubes: [ + [0, 0, 0, 5, 2, depth], + [0, 3, 0, 5, 5, depth], + [0, 6, 0, 5, 8, depth], + [1.5, 8, 0, 3.5, 9, depth], + [1.5, -1, 0, 3.5, 0, depth], + [3, 2, 0, 5, 3, depth], + [0, 5, 0, 2, 6, depth], + ] + }, + t: { + width: 5, + cubes: [ + [1.5, 0, 0, 3.5, 6, depth], + [0, 6, 0, 5, 8, depth], + ] + }, + u: { + width: 6, + cubes: [ + [0, 0, 0, 2, 8, depth], + [4, 0, 0, 6, 8, depth], + [2, 0, 0, 4, 2, depth] + ] + }, + v: { + width: 5.75, + cubes: [ + [0, 4, 0, 2, 8, depth], + [3.75, 4, 0, 5.75, 8, depth], + [0.75, 2, 0, 5, 4, depth], + [1.75, 0, 0, 3.75, 2, depth], + ] + }, + w: { + width: 6.5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [4.5, 0, 0, 6.5, 8, depth], + [2, 1, 0, 2.5, 4, depth], + [4, 1, 0, 4.5, 4, depth], + [2.5, 2, 0, 4, 6, depth], + ] + }, + x: { + width: 5, + cubes: [ + [0, 0, 0, 2, 2.75, depth], + [0, 5.25, 0, 2, 8, depth], + [3, 5.25, 0, 5, 8, depth], + [3, 0, 0, 5, 2.75, depth], + [1.25, 2.25, 0, 3.75, 5.75, depth], + ] + }, + y: { + width: 5, + cubes: [ + [0, 4, 0, 2, 8, depth], + [3, 4, 0, 5, 8, depth], + [1.25, 0, 0, 3.75, 5, depth], + ] + }, + z: { + width: 5, + cubes: [ + [0, 0, 0, 5, 2, depth], + [0, 6, 0, 5, 8, depth], + [0, 2, 0, 2, 3, depth], + [1, 3, 0, 3, 4, depth], + [2, 4, 0, 4, 5, depth], + [3, 5, 0, 5, 6, depth], + ] + }, + 0: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 3, 8, depth], + [3, 0, 0, 5, 8, depth], + [2, 0, 0, 3, 2, depth] + ] + }, + 1: { + width: 3, + cubes: [ + [0, 5, 0, 1, 7, depth], + [1, 0, 0, 3, 8, depth], + ] + }, + 2: { + width: 5, + cubes: [ + [0, 0, 0, 5, 2, depth], + [0, 3, 0, 5, 5, depth], + [0, 6, 0, 5, 8, depth], + [0, 2, 0, 2, 3, depth], + [3, 5, 0, 5, 6, depth], + ] + }, + 3: { + width: 5, + cubes: [ + [0, 0, 0, 5, 2, depth], + [0, 3, 0, 5, 5, depth], + [0, 6, 0, 5, 8, depth], + [3, 2, 0, 5, 3, depth], + [3, 5, 0, 5, 6, depth], + ] + }, + 4: { + width: 5, + cubes: [ + [0, 3, 0, 2, 8, depth], + [2, 3, 0, 3, 5, depth], + [3, 0, 0, 5, 8, depth], + ] + }, + 5: { + width: 5, + cubes: [ + [0, 0, 0, 4.25, 2, depth], + [0, 3, 0, 4.25, 5, depth], + [0, 6, 0, 5, 8, depth], + [3, 2, 0, 4.25, 3, depth], + [0, 5, 0, 2, 6, depth], + [4.25, 0.5, 0, 5, 4.5, depth], + ] + }, + 6: { + width: 5, + cubes: [ + [0, 0, 0, 5, 2, depth], + [0, 3, 0, 5, 5, depth], + [0, 6, 0, 5, 8, depth], + [3, 2, 0, 5, 3, depth], + [0, 2, 0, 2, 3, depth], + [0, 5, 0, 2, 6, depth], + ] + }, + 7: { + width: 5, + cubes: [ + [0, 6, 0, 5, 8, depth], + [3, 0, 0, 5, 6, depth], + ] + }, + 8: { + width: 5, + cubes: [ + [0, 0, 0, 2, 8, depth], + [3, 0, 0, 5, 8, depth], + [2, 6, 0, 3, 8, depth], + [2, 0, 0, 3, 2, depth], + [2, 3, 0, 3, 5, depth], + ] + }, + 9: { + width: 5, + cubes: [ + [0, 6, 0, 5, 8, depth], + [0, 3, 0, 3, 5, depth], + [0, 0, 0, 3, 2, depth], + [0, 5, 0, 2, 6, depth], + [3, 0, 0, 5, 6, depth], + ] + }, + ".": { + width: 3, + cubes: [ + [0, 0, 0, 2, 2, depth] + ] + }, + "!": { + width: 2, + cubes: [ + [0, 0, 0, 2, 2, depth], + [0, 4, 0, 2, 8, depth] + ] + }, + "-": { + width: 4, + cubes: [ + [0, 3, 0, 4, 5, depth] + ] + }, + "+": { + width: 4, + cubes: [ + [0, 3.25, 0, 4, 4.75, depth], + [1.25, 2, 0, 2.75, 6, depth] + ] + }, + ":": { + width: 2, + cubes: [ + [0, 0, 0, 2, 2, depth], + [0, 4, 0, 2, 6, depth] + ] + }, + ";": { + width: 2, + cubes: [ + [0, 4, 0, 2, 6, depth], + [0, -1, 0, 2, 2, depth] + ] + }, + ",": { + width: 2, + cubes: [ + [0, -1, 0, 2, 2, depth] + ] + }, + "'": { + width: 2, + cubes: [ + [0, 6, 0, 2, 9, depth] + ] + }, + "?": { + width: 4, + cubes: [ + [1, 0, 0, 3, 2, depth], + [1, 3, 0, 3, 5, depth], + [0, 6, 0, 4, 8, depth], + [2, 5, 0, 4, 6, depth] + ] + }, + "[": { + width: 3, + cubes: [ + [0, 0, 0, 2, 8, depth], + [2, 6, 0, 3, 8, depth], + [2, 0, 0, 3, 2, depth] + ] + }, + "(": { + width: 3, + cubes: [ + [0, 1, 0, 2, 7, depth], + [1, 6, 0, 3, 8, depth], + [1, 0, 0, 3, 2, depth] + ] + }, + "]": { + width: 3, + cubes: [ + [1, 0, 0, 3, 8, depth], + [0, 6, 0, 1, 8, depth], + [0, 0, 0, 1, 2, depth] + ] + }, + ")": { + width: 3, + cubes: [ + [1, 1, 0, 3, 7, depth], + [0, 6, 0, 2, 8, depth], + [0, 0, 0, 2, 2, depth] + ] + }, + "/": { + width: 6, + cubes: [ + [0, 0, 0, 3, 2, depth], + [1, 2, 0, 4, 4, depth], + [2, 4, 0, 5, 6, depth], + [3, 6, 0, 6, 8, depth], + ] + }, + " ": { + width: wordSpace, + cubes: [] + } + } + } + + + function prepareDefaultText(text) { + return String(text).replace(/\r\n/g, '\n').replace(/\n/g, '\\').toLowerCase(); + } + + function splitDefaultLines(text) { + const normalized = prepareDefaultText(text); + const lines = []; + let current = ''; + for (let i = 0; i < normalized.length; i++) { + const char = normalized[i]; + if (char === '\\') { + lines.push(current); + current = ''; + } else { + current += char; + } + } + lines.push(current); + return lines; + } + + function measureDefaultLineWidth(line, charMap, letterSpace) { + if (!line.length) return 0; + let width = 0; + for (const char of line) { + if (!charMap[char]) continue; + width += charMap[char].width + letterSpace; + } + return Math.max(0, width - letterSpace); + } + + function estimateDefaultElementCount(text, depth, letterSpacing, wordSpacing) { + const letterSpace = letterSpacing || 0; + const wordSpace = wordSpacing || 0; + const renderDepth = resolveRenderDepth(depth); + const charMap = buildDefaultCharMap(renderDepth, wordSpace); + let count = 0; + for (const line of splitDefaultLines(text)) { + for (const char of line) { + if (charMap[char]) count += charMap[char].cubes.length; + } + } + return count; + } + + function centerTextGroup(group) { + if (!group) return; + const elements = []; + group.forEachChild(c => { + if (c instanceof Cube) elements.push(c); + }, Cube, true); + if (!elements.length) return; + for (const axis of [0, 1, 2]) { + let min = Infinity, max = -Infinity; + for (const el of elements) { + min = Math.min(min, el.from[axis], el.to[axis]); + max = Math.max(max, el.from[axis], el.to[axis]); + } + const offset = -Math.round((min + max) / 2); + for (const el of elements) { + el.from[axis] += offset; + el.to[axis] += offset; + } + } + } + + function extractFormSettings(formData) { + return { + text: formData.text, + font: formData.font, + fontSize: formData.fontSize, + depth: formData.depth, + outlineColor: formData.outlineColor, + outlineEnabled: formData.outlineEnabled, + letterSpacing: formData.letterSpacing, + lineSpacing: formData.lineSpacing, + wordSpacing: formData.wordSpacing, + alignment: formData.alignment, + optimizeGeometry: formData.optimizeGeometry, + bold: formData.bold, + italic: formData.italic, + underline: formData.underline, + strikethrough: formData.strikethrough, + pluginVersion: PLUGIN_VERSION + }; + } + + function attachTextGroupMetadata(group, text, options) { + const settings = extractFormSettings({ ...options, text: text }); + if (typeof group.extend === 'function') { + group.extend({ [TGP_META_KEY]: settings }); + } else { + group[TGP_META_KEY] = settings; + } + } + + function isTextRootGroup(group) { + return group instanceof Group && ( + !!group[TGP_META_KEY] || + (typeof group.name === 'string' && group.name.startsWith(TGP_TEXT_ROOT_PREFIX)) + ); + } + + function findTextRootGroup(node) { + if (!node) return null; + let current = node; + while (current && current !== 'root') { + if (isTextRootGroup(current)) return current; + current = current.parent; + } + return null; + } + + function getTextFromGroupName(group) { + if (group && group.name && group.name.startsWith(TGP_TEXT_ROOT_PREFIX)) { + return group.name.slice(TGP_TEXT_ROOT_PREFIX.length); + } + return ''; + } + + function getGroupElementsCenter(group) { + let min = [Infinity, Infinity, Infinity]; + let max = [-Infinity, -Infinity, -Infinity]; + let found = false; + group.forEachChild(c => { + if (!(c instanceof Cube)) return; + found = true; + for (const axis of [0, 1, 2]) { + min[axis] = Math.min(min[axis], c.from[axis], c.to[axis]); + max[axis] = Math.max(max[axis], c.from[axis], c.to[axis]); + } + }, Cube, true); + if (!found) return null; + return [ + Math.round((min[0] + max[0]) / 2), + Math.round((min[1] + max[1]) / 2), + Math.round((min[2] + max[2]) / 2) + ]; + } + + function alignGroupToCenter(group, targetCenter) { + if (!group || !targetCenter) return; + const currentCenter = getGroupElementsCenter(group); + if (!currentCenter) return; + const delta = [ + targetCenter[0] - currentCenter[0], + targetCenter[1] - currentCenter[1], + targetCenter[2] - currentCenter[2] + ]; + group.forEachChild(c => { + if (!(c instanceof Cube)) return; + for (const axis of [0, 1, 2]) { + c.from[axis] += delta[axis]; + c.to[axis] += delta[axis]; + } + }, Cube, true); + } + + function openTextEditorForGroup(rootGroup) { + if (!rootGroup) return; + const now = Date.now(); + if (openTextEditorForGroup._lastOpen && now - openTextEditorForGroup._lastOpen < 400) return; + openTextEditorForGroup._lastOpen = now; + + pendingEditContext = { + group: rootGroup, + center: getGroupElementsCenter(rootGroup) + }; + dialog = createDialog(true); + dialog.show(); + setTimeout(() => { + const settings = rootGroup[TGP_META_KEY]; + if (settings) { + const values = Object.assign({}, settings); + normalizeFormFontFields(values); + dialog.setFormValues(values, false); + } else { + dialog.setFormValues({ + text: getTextFromGroupName(rootGroup) + }, false); + } + }, 50); + } + + function getSelectedOutlinerGroup() { + if (Group.first_selected) return Group.first_selected; + if (Group.selected && Group.selected.length) return Group.selected[0]; + if (typeof Outliner !== 'undefined' && Outliner.selected && Outliner.selected.length) { + const node = Outliner.selected[0]; + if (node instanceof Group) return node; + return findTextRootGroup(node); + } + return null; + } + + function setupOutlinerDoubleClickEdit() { + if (typeof Group !== 'undefined' && Group.addBehaviorOverride) { + groupBehaviorOverride = Group.addBehaviorOverride({ + condition: (group) => !!findTextRootGroup(group), + priority: 20, + behavior: { + dblclick(event, group) { + const root = findTextRootGroup(group); + if (!root) return; + if (event && event.preventDefault) event.preventDefault(); + if (event && event.stopPropagation) event.stopPropagation(); + openTextEditorForGroup(root); + return false; + } + } + }); + } + + outlinerDblClickHandler = (event) => { + if (event.target.closest('input, textarea, .name_editor, .rename')) return; + setTimeout(() => { + const selected = getSelectedOutlinerGroup(); + const root = findTextRootGroup(selected); + if (!root) return; + openTextEditorForGroup(root); + }, 0); + }; + + outlinerDblClickPanel = document.querySelector('#outliner') + || document.querySelector('#panel_outliner') + || document.querySelector('.outliner_node_list'); + if (outlinerDblClickPanel) { + outlinerDblClickPanel.addEventListener('dblclick', outlinerDblClickHandler, true); + } + } + + function teardownOutlinerDoubleClickEdit() { + if (groupBehaviorOverride && groupBehaviorOverride.delete) { + groupBehaviorOverride.delete(); + groupBehaviorOverride = null; + } + if (outlinerDblClickPanel && outlinerDblClickHandler) { + outlinerDblClickPanel.removeEventListener('dblclick', outlinerDblClickHandler, true); + } + outlinerDblClickPanel = null; + outlinerDblClickHandler = null; + pendingEditContext = null; + } + + function sanitizeGroupCharName(char) { + if (char === ' ') return '_space'; + if (char === '\n') return '_newline'; + return char.replace(/[^\w\-]/g, '_') || '_char'; + } + + function makeLetterGroupName(char, usedNames) { + const base = sanitizeGroupCharName(char); + if (!usedNames[base]) { + usedNames[base] = 1; + return base; + } + usedNames[base]++; + return base + '_' + usedNames[base]; + } + + function getOrCreateLetterGroup(mainGroup, letterGroups, index, char, usedNames) { + if (letterGroups[index]) return letterGroups[index]; + const name = makeLetterGroupName(char, usedNames); + const group = new Group({ name: name, origin: [0, 0, 0] }).init(); + group.addTo(mainGroup); + letterGroups[index] = group; + return group; + } + + function buildFillCellCharMap(fillPositions) { + const map = new Map(); + for (const pos of fillPositions) { + const x1 = snapCoord(Math.min(pos.from[0], pos.to[0])); + const x2 = snapCoord(Math.max(pos.from[0], pos.to[0])); + const y1 = snapCoord(Math.min(pos.from[1], pos.to[1])); + const y2 = snapCoord(Math.max(pos.from[1], pos.to[1])); + for (let x = x1; x < x2; x++) { + for (let y = y1; y < y2; y++) { + map.set(x + ',' + y, pos.charIndex); + } + } + } + return map; + } + + function findLetterIndexAdjacentToCell(x, y, cellMap) { + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const [dx, dy] of dirs) { + const key = (x + dx) + ',' + (y + dy); + if (cellMap.has(key)) return cellMap.get(key); + } + return -1; + } + + function findLetterIndexAdjacentToOutline(px, py, pixels, width, height, characters) { + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const [dx, dy] of dirs) { + const nx = px + dx; + const ny = py + dy; + if (nx >= 0 && ny >= 0 && nx < width && ny < height && pixels[ny][nx] === 1) { + return findCharacterAt(nx, ny, characters); + } + } + return 0; + } + + function collectOutlinePositionsFromPixels(pixels, width, height) { + const fillSet = new Set(); + const outlineSet = new Set(); + for (let py = 0; py < height; py++) { + for (let px = 0; px < width; px++) { + if (pixels[py][px] === 1) fillSet.add(px + ',' + py); + } + } + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const key of fillSet) { + const [px, py] = key.split(',').map(Number); + for (const [dx, dy] of dirs) { + const nx = px + dx; + const ny = py + dy; + const nkey = nx + ',' + ny; + if (nx >= 0 && ny >= 0 && nx < width && ny < height && !fillSet.has(nkey)) { + outlineSet.add(nkey); + } + } + } + return outlineSet; + } + + function collectOutlinePositionsFromCubes(cubes) { + const fillSet = new Set(); + for (const cube of cubes) { + const x1 = snapCoord(Math.min(cube.from[0], cube.to[0])); + const x2 = snapCoord(Math.max(cube.from[0], cube.to[0])); + const y1 = snapCoord(Math.min(cube.from[1], cube.to[1])); + const y2 = snapCoord(Math.max(cube.from[1], cube.to[1])); + for (let x = x1; x < x2; x++) { + for (let y = y1; y < y2; y++) { + fillSet.add(x + ',' + y); + } + } + } + const outlineSet = new Set(); + const dirs = [[1, 0], [-1, 0], [0, 1], [0, -1]]; + for (const key of fillSet) { + const [x, y] = key.split(',').map(Number); + for (const [dx, dy] of dirs) { + const nkey = (x + dx) + ',' + (y + dy); + if (!fillSet.has(nkey)) outlineSet.add(nkey); + } + } + return outlineSet; + } + + function generateDefaultText3D(text, options) { + prepareFormColors(options); + const renderDepth = resolveRenderDepth(options.depth); + const letterSpace = options.letterSpacing || 0; + const wordSpace = options.wordSpacing || 0; + const charMap = buildDefaultCharMap(renderDepth, wordSpace); + const lines = splitDefaultLines(text); + const displayText = String(text).substring(0, 20); + const outlineColorInt = hexToInt(normalizeColor(options.outlineColor)); + + Undo.initEdit({ outliner: true, elements: [], textures: [] }); + + const mainGroup = new Group({ name: 'Text: ' + displayText, origin: [0, 0, 0] }).init(); + mainGroup.addTo(); + attachTextGroupMetadata(mainGroup, text, options); + + const allCubes = []; + const fillPositions = []; + const letterGroups = []; + const usedNames = {}; + let invalidCount = 0; + let charIndex = 0; + const lineWidths = lines.map(line => measureDefaultLineWidth(line, charMap, letterSpace)); + let yOffset = 0; + const lineStep = 10 + (options.lineSpacing ?? 0); + + lines.forEach((line, lineIndex) => { + let offset = 0; + const lineWidth = lineWidths[lineIndex]; + if (options.alignment === 'center') offset -= Math.round(lineWidth / 2); + else if (options.alignment === 'right') offset -= Math.round(lineWidth); + + for (const char of line) { + if (!charMap.hasOwnProperty(char)) { + invalidCount++; + continue; + } + + if (char !== ' ') { + const letterGroup = getOrCreateLetterGroup(mainGroup, letterGroups, charIndex, char, usedNames); + + for (const cubeDef of charMap[char].cubes) { + fillPositions.push({ + from: [ + snapCoord(cubeDef[0] + offset), + snapCoord(cubeDef[1] - yOffset), + snapCoord(cubeDef[2]) + ], + to: [ + snapCoord(cubeDef[3] + offset), + snapCoord(cubeDef[4] - yOffset), + snapCoord(cubeDef[5]) + ], + targetGroup: letterGroup, + charIndex: charIndex + }); + } + charIndex++; + } + + offset += charMap[char].width + letterSpace; + } + + yOffset += lineStep; + }); + + if (options.outlineEnabled) { + const fillCellMap = buildFillCellCharMap(fillPositions); + const tempCubes = fillPositions.map(p => ({ from: p.from, to: p.to })); + const outlinePositions = collectOutlinePositionsFromCubes(tempCubes); + for (const key of outlinePositions) { + const [x, y] = key.split(',').map(Number); + const adjCharIndex = findLetterIndexAdjacentToCell(x, y, fillCellMap); + const outlineGroup = adjCharIndex >= 0 && letterGroups[adjCharIndex] + ? letterGroups[adjCharIndex] + : mainGroup; + const outlineCube = new Cube({ + name: 'outline', + from: [x, y, 0], + to: [x + 1, y + 1, renderDepth], + box_uv: true, + color: outlineColorInt + }).init(); + outlineCube.flip(0, 2.0, true); + outlineCube.addTo(outlineGroup); + allCubes.push(outlineCube); + } + } + + fillPositions.forEach((pos) => { + const textCube = new Cube({ + name: 'cube', + from: pos.from.slice(), + to: pos.to.slice(), + box_uv: true, + color: DEFAULT_TEXT_COLOR + }).init(); + textCube.flip(0, 2.0, true); + textCube.addTo(pos.targetGroup); + allCubes.push(textCube); + }); + + centerTextGroup(mainGroup); + Undo.finishEdit('Text Generator', { elements: allCubes, textures: [], outliner: true }); + refreshTextScene(); + + let message = 'Generated ' + allCubes.length + ' element' + (allCubes.length === 1 ? '' : 's'); + if (invalidCount > 0) message += '. Skipped invalid character(s).'; + Blockbench.showQuickMessage(message); + + return { cubeCount: allCubes.length, group: mainGroup, letterGroups: letterGroups }; + } + + + // ============================================ + // COLOR MANAGEMENT + // ============================================ + function normalizeColor(value) { + if (value == null || value === '') return '#ffffff'; + if (typeof value === 'string') { + const hex = value.trim(); + if (/^#[0-9a-fA-F]{3,8}$/i.test(hex)) return hex.length === 4 ? expandShortHex(hex) : hex.slice(0, 7); + if (/^[0-9a-fA-F]{3,8}$/i.test(hex)) return normalizeColor('#' + hex); + return hex; + } + if (typeof value.toHexString === 'function') return value.toHexString(); + if (typeof value.toHex === 'function') return value.toHex(); + if (value._r != null) return rgbToHex(value._r, value._g, value._b); + return '#ffffff'; + } + + function expandShortHex(hex) { + if (hex.length !== 4) return hex; + return '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3]; + } + + function hexToRgb(hex) { + const normalized = normalizeColor(hex); + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(normalized); + return result ? { + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16) + } : { r: 255, g: 255, b: 255 }; + } + + function hexToInt(hex) { + const rgb = hexToRgb(hex); + return (rgb.r << 16) + (rgb.g << 8) + rgb.b; + } + + function rgbToHex(r, g, b) { + return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1); + } + + function findCharacterAt(px, py, characters) { + for (let i = 0; i < characters.length; i++) { + const c = characters[i]; + if (px >= c.x && px < c.x + c.w && py >= c.y && py < c.y + c.h) return i; + } + return 0; + } + + function refreshTextScene() { + Canvas.updateAll(); + } + + // ============================================ + // CANVAS RENDER & PIXEL ANALYSIS + // ============================================ + function splitTextLines(text) { + return String(text).replace(/\\n/g, '\n').split(/\r?\n/); + } + + function measureCanvasLineWidth(line, ctx, inversionMode, cellStep, letterSpacing, wordSpacing) { + if (inversionMode) { + let width = 0; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (/\s/.test(char)) { + width += Math.max(cellStep, wordSpacing || 0); + } else { + width += cellStep; + } + } + return width; + } + + let width = 0; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + const charWidth = ctx.measureText(char).width; + if (/\s/.test(char)) { + width += charWidth + (wordSpacing || 0); + } else { + width += charWidth; + if (i < line.length - 1) { + width += letterSpacing || 0; + } + } + } + return width; + } + + function getLineStartX(lineWidth, maxWidth, alignment, padding) { + if (alignment === 'center') return padding + Math.round((maxWidth - lineWidth) / 2); + if (alignment === 'right') return padding + Math.round(maxWidth - lineWidth); + return padding; + } + + function getPixelData(text, fontId, fontSize, bold, italic, underline, strikethrough, lineSpacing, letterSpacing, wordSpacing, alignment) { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + const inversionMode = isInversionFont(fontId); + + const fontStyle = buildCanvasFontStyle(fontId, fontSize, bold, italic); + ctx.font = fontStyle; + + const lines = splitTextLines(text); + let maxWidth = 0; + const lineMetrics = []; + const lineHeight = fontSize + (lineSpacing || 0); + const cellStep = fontSize + (letterSpacing || 0); + + lines.forEach(line => { + const lineWidth = measureCanvasLineWidth(line, ctx, inversionMode, cellStep, letterSpacing, wordSpacing); + maxWidth = Math.max(maxWidth, lineWidth); + lineMetrics.push({ width: lineWidth }); + }); + + const totalHeight = lines.length * lineHeight - (lines.length > 0 ? (lineSpacing || 0) : 0); + + const padding = 4; + canvas.width = Math.ceil(maxWidth) + padding * 2; + canvas.height = Math.ceil(totalHeight) + padding * 2; + + ctx.font = fontStyle; + ctx.textBaseline = 'top'; + ctx.fillStyle = '#000000'; + + let y = padding; + const characters = []; + const words = []; + lines.forEach((line, i) => { + const metrics = lineMetrics[i]; + const lineStartX = getLineStartX(metrics.width, maxWidth, alignment || 'left', padding); + let charX = lineStartX; + let wordStart = lineStartX; + let inWord = false; + + if (inversionMode) { + for (const char of line) { + const isSpace = /\s/.test(char); + + if (!isSpace) { + if (!inWord) { + wordStart = charX; + inWord = true; + } + drawInversionGlyphCell(ctx, char, charX, y, fontSize, fontStyle); + characters.push({ + x: charX, + y: y, + w: fontSize, + h: fontSize, + char: char, + lineIndex: i + }); + charX += cellStep; + } else { + if (inWord) { + words.push({ + x: wordStart, + y: y, + w: charX - wordStart, + h: fontSize, + lineIndex: i + }); + inWord = false; + } + charX += Math.max(cellStep, wordSpacing || 0); + } + } + } else { + for (let ci = 0; ci < line.length; ci++) { + const char = line[ci]; + const charWidth = ctx.measureText(char).width; + const isSpace = /\s/.test(char); + + if (!isSpace) { + if (!inWord) { + wordStart = charX; + inWord = true; + } + ctx.fillText(char, charX, y); + characters.push({ + x: charX, + y: y, + w: Math.ceil(charWidth), + h: fontSize, + char: char, + lineIndex: i + }); + charX += charWidth; + if (ci < line.length - 1) { + charX += letterSpacing || 0; + } + } else { + if (inWord) { + words.push({ + x: wordStart, + y: y, + w: charX - wordStart, + h: fontSize, + lineIndex: i + }); + inWord = false; + } + charX += charWidth + (wordSpacing || 0); + } + } + } + + if (inWord) { + words.push({ + x: wordStart, + y: y, + w: charX - wordStart, + h: fontSize, + lineIndex: i + }); + } + + if (underline) { + ctx.fillRect(lineStartX, y + fontSize - 2, metrics.width, 2); + } + if (strikethrough) { + ctx.fillRect(lineStartX, y + Math.round(fontSize / 2) - 1, metrics.width, 2); + } + + y += lineHeight; + }); + + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const pixels = []; + const alphaThreshold = inversionMode ? 64 : 128; + + for (let py = 0; py < canvas.height; py++) { + const row = []; + for (let px = 0; px < canvas.width; px++) { + const idx = (py * canvas.width + px) * 4; + const alpha = imageData.data[idx + 3]; + row.push(alpha > alphaThreshold ? 1 : 0); + } + pixels.push(row); + } + + return { + pixels: pixels, + width: canvas.width, + height: canvas.height, + lineCount: lines.length, + characters: characters, + words: words + }; + } + + // ============================================ + // 3D CUBE GENERATION + // ============================================ + function countFilledPixels(pixels, width, height) { + let count = 0; + for (let py = 0; py < height; py++) { + for (let px = 0; px < width; px++) { + if (pixels[py][px] === 1) count++; + } + } + return count; + } + + function estimateElementCount(pixels, width, height, depth, optimizeGeometry, outlineEnabled) { + const filled = countFilledPixels(pixels, width, height); + const renderDepth = resolveRenderDepth(depth); + let count; + if (optimizeGeometry !== false) { + count = filled; + } else if (renderDepth === 0) { + count = filled; + } else { + count = filled * renderDepth; + } + if (outlineEnabled) { + count += collectOutlinePositionsFromPixels(pixels, width, height).size; + } + return count; + } + + function createTextColumn(fromX, fromY, renderDepth, fillColor, group, index) { + const cube = new Cube({ + name: 'text_col_' + index, + from: [snapCoord(fromX), snapCoord(fromY), 0], + to: [snapCoord(fromX) + 1, snapCoord(fromY) + 1, snapCoord(renderDepth)], + box_uv: true, + color: fillColor + }).init(); + + cube.flip(0, 2.0, true); + cube.addTo(group); + return cube; + } + + function createTextCube(x, y, z, fillColor, group) { + const cube = new Cube({ + name: 'text_pixel_' + x + '_' + y + '_' + z, + from: [snapCoord(x), snapCoord(y), snapCoord(z)], + to: [snapCoord(x) + 1, snapCoord(y) + 1, snapCoord(z) + 1], + box_uv: true, + color: fillColor + }).init(); + + cube.flip(0, 2.0, true); + cube.addTo(group); + return cube; + } + + function updateCanvasElements(elements, options = {}) { + refreshTextScene(); + } + + function resolveLetterGroup(px, py, mainGroup, letterGroups, characters, usedNames) { + const letterIndex = findCharacterAt(px, py, characters); + const char = characters[letterIndex]?.char || '_'; + return getOrCreateLetterGroup(mainGroup, letterGroups, letterIndex, char, usedNames); + } + + function generateText3D(text, options, precomputedPixelData) { + const { + font, fontSize, depth, + outlineColor, outlineEnabled, + letterSpacing, lineSpacing, wordSpacing, alignment, + bold, italic, underline, strikethrough, + optimizeGeometry + } = options; + + const useOptimizedGeometry = optimizeGeometry !== false; + const renderDepth = resolveRenderDepth(depth); + const outlineColorInt = hexToInt(normalizeColor(outlineColor)); + + const pixelData = precomputedPixelData || getPixelData( + text, font, fontSize, bold, italic, underline, strikethrough, + lineSpacing, letterSpacing, wordSpacing, alignment + ); + const pixels = pixelData.pixels; + const width = pixelData.width; + const height = pixelData.height; + const characters = pixelData.characters || []; + + Undo.initEdit({ outliner: true, elements: [], textures: [] }); + + const mainGroup = new Group({ + name: 'Text: ' + text.substring(0, 20), + origin: [0, 0, 0] + }).init(); + mainGroup.addTo(); + attachTextGroupMetadata(mainGroup, text, options); + + const allCubes = []; + const letterGroups = []; + const usedNames = {}; + + if (outlineEnabled) { + const outlinePositions = collectOutlinePositionsFromPixels(pixels, width, height); + for (const key of outlinePositions) { + const [px, py] = key.split(',').map(Number); + const letterIndex = findLetterIndexAdjacentToOutline(px, py, pixels, width, height, characters); + const char = characters[letterIndex]?.char || '_'; + const outlineGroup = getOrCreateLetterGroup(mainGroup, letterGroups, letterIndex, char, usedNames); + const fromX = px; + const fromY = height - py; + const outlineCube = createTextColumn( + fromX, fromY, renderDepth, outlineColorInt, outlineGroup, 'outline' + ); + outlineCube.name = 'outline'; + allCubes.push(outlineCube); + } + } + + if (useOptimizedGeometry) { + let colIndex = 0; + for (let py = 0; py < height; py++) { + for (let px = 0; px < width; px++) { + if (pixels[py][px] !== 1) continue; + + const fromX = px; + const fromY = height - py; + const targetGroup = resolveLetterGroup( + px, py, mainGroup, letterGroups, characters, usedNames + ); + + const cube = createTextColumn( + fromX, fromY, renderDepth, DEFAULT_TEXT_COLOR, targetGroup, colIndex + ); + cube.name = 'cube'; + allCubes.push(cube); + colIndex++; + } + } + } else { + for (let py = 0; py < height; py++) { + for (let px = 0; px < width; px++) { + if (pixels[py][px] !== 1) continue; + + const x = px; + const y = height - py; + const targetGroup = resolveLetterGroup( + px, py, mainGroup, letterGroups, characters, usedNames + ); + + if (renderDepth === 0) { + const cube = createTextColumn( + x, y, 0, DEFAULT_TEXT_COLOR, targetGroup, 'flat_' + px + '_' + py + ); + cube.name = 'cube'; + allCubes.push(cube); + } else { + for (let d = 0; d < renderDepth; d++) { + const cube = createTextCube(x, y, d, DEFAULT_TEXT_COLOR, targetGroup); + cube.name = 'cube'; + allCubes.push(cube); + } + } + } + } + } + + centerTextGroup(mainGroup); + Undo.finishEdit('Text Generator', { elements: allCubes, textures: [], outliner: true }); + updateCanvasElements(allCubes, { outliner: true, fullRefresh: true }); + + Blockbench.showQuickMessage('Generated ' + allCubes.length + ' element' + (allCubes.length === 1 ? '' : 's')); + + return { + cubeCount: allCubes.length, + group: mainGroup, + letterGroups: letterGroups + }; + } + + function prepareTextOptions(formData) { + prepareFormColors(formData); + return formData; + } + + function runTextGeneration(formData, precomputedPixelData, editContext) { + let result; + if (isDefaultFont(formData.font)) { + result = generateDefaultText3D(formData.text, formData); + } else { + result = generateText3D(formData.text, formData, precomputedPixelData); + } + if (editContext && editContext.center && result && result.group) { + alignGroupToCenter(result.group, editContext.center); + } + return result; + } + + function executeTextGeneration(formData, precomputedPixelData, editContext) { + if (editContext && editContext.group) { + editContext.group.remove(true); + } + return runTextGeneration(formData, precomputedPixelData, editContext); + } + + function confirmTextGeneration(formData, editContext) { + prepareTextOptions(formData); + normalizeFormFontFields(formData); + + if (isDefaultFont(formData.font)) { + const estimate = estimateDefaultElementCount( + formData.text, formData.depth, formData.letterSpacing, formData.wordSpacing + ); + const generate = () => executeTextGeneration(formData, null, editContext); + if (estimate > 3000) { + Blockbench.showMessageBox({ + title: 'Performance Warning', + message: 'This text will create approximately ' + estimate + ' elements, which may reduce Blockbench FPS.\n\nContinue generating?', + buttons: ['Generate', 'Cancel'], + confirm: 0, + cancel: 1 + }, (result) => { + if (result === 0) generate(); + }); + } else { + generate(); + } + return; + } + + const pixelData = getPixelData( + formData.text, + formData.font, + formData.fontSize, + formData.bold, + formData.italic, + formData.underline, + formData.strikethrough, + formData.lineSpacing, + formData.letterSpacing, + formData.wordSpacing, + formData.alignment + ); + + const estimate = estimateElementCount( + pixelData.pixels, + pixelData.width, + pixelData.height, + formData.depth, + formData.optimizeGeometry !== false, + formData.outlineEnabled + ); + + const generate = () => executeTextGeneration(formData, pixelData, editContext); + + if (estimate > 3000) { + Blockbench.showMessageBox({ + title: 'Performance Warning', + message: 'This text will create approximately ' + estimate + ' elements, which may reduce Blockbench FPS. Large outlines also affect performance.\n\nContinue generating?', + buttons: ['Generate', 'Cancel'], + confirm: 0, + cancel: 1 + }, (result) => { + if (result === 0) generate(); + }); + } else { + generate(); + } + } + + // ============================================ + // LIVE PREVIEW + // ============================================ + function clearPreview() { + if (previewGroup) { + previewGroup.remove(); + previewGroup = null; + } + previewCubes.forEach(c => c.remove()); + previewCubes = []; + updateCanvasElements([], { outliner: true }); + } + + function updatePreview(formData) { + clearPreview(); + + if (!formData.text || formData.text.trim() === '') return; + + // Lightweight version for preview + const previewOptions = { + font: formData.font, + fontSize: Math.min(formData.fontSize, 16), + depth: Math.min(formData.depth, 2), + outlineColor: formData.outlineColor || '#000000', + outlineEnabled: formData.outlineEnabled, + letterSpacing: formData.letterSpacing, + lineSpacing: formData.lineSpacing, + wordSpacing: formData.wordSpacing, + alignment: formData.alignment, + optimizeGeometry: true, + bold: formData.bold, + italic: formData.italic, + underline: formData.underline, + strikethrough: formData.strikethrough + }; + + try { + normalizeFormFontFields(previewOptions); + const result = isDefaultFont(previewOptions.font) + ? generateDefaultText3D(formData.text, previewOptions) + : generateText3D(formData.text, previewOptions); + previewGroup = result.group; + if (previewGroup) { + previewCubes = previewGroup.children.filter(c => c instanceof Cube); + } + } catch(e) { + console.error('Preview error:', e); + } + } + + // ============================================ + // DIALOG FORM + // ============================================ + function prepareFormColors(formData) { + formData.outlineColor = normalizeColor(formData.outlineColor); + return formData; + } + + function cleanupOrphanColorPickers() { + document.querySelectorAll('body > .sp-container').forEach(el => el.remove()); + } + + function attachColorPickerHexFix(dialogInstance) { + const fixHexConfirm = (event) => { + const chooseBtn = event.target.closest?.('.sp-choose'); + if (!chooseBtn || !dialogInstance.object?.isConnected) return; + + const container = chooseBtn.closest('.sp-container'); + const hexInput = container?.querySelector('input.sp-input'); + if (!hexInput?.value) return; + + let hex = hexInput.value.trim(); + if (!hex.startsWith('#')) hex = '#' + hex; + if (!/^#[0-9a-fA-F]{3,8}$/i.test(hex)) return; + + const formData = dialogInstance.form?.form_data; + if (!formData) return; + + for (const key of ['outlineColor']) { + const picker = formData[key]?.colorpicker; + if (!picker?.jq?.length) continue; + + try { + if (picker.jq.spectrum('container')?.[0] !== container) continue; + picker.set(hex); + return; + } catch (e) { + continue; + } + } + }; + + document.addEventListener('click', fixHexConfirm, true); + return fixHexConfirm; + } + + function detachColorPickerHexFix(handler) { + if (handler) document.removeEventListener('click', handler, true); + } + + function createDialog(isEditMode) { + // Remove leftover Spectrum popups from a previous session before building new pickers. + cleanupOrphanColorPickers(); + const fontOptions = getFontOptions(); + + return new Dialog({ + id: 'text_generator', + title: isEditMode ? PLUGIN_NAME + ' -- Edit Text' : PLUGIN_NAME, + width: 720, + cancel_on_click_outside: false, + lines: [` + + `], + form: { + edit_info: { + type: 'info', + text: 'Double-click opened this editor. Confirm to replace the existing text group at the same position.', + condition: () => !!isEditMode + }, + + // === TEXT INPUT === + text: { + label: 'Text', + type: 'textarea', + value: 'Hello World', + placeholder: 'Enter your text here... Press Enter for new lines' + }, + + // === FONT === + font_section: { type: 'info', text: '
📝 Font Settings
' }, + font: { + label: 'Font Family', + type: 'select', + options: fontOptions, + value: 'default' + }, + fontSize: { + label: 'Font Size (px)', + type: 'number', + value: 16, + min: 4, + max: 128, + step: 1, + condition: (form) => form.font !== 'default' + }, + customFont: { + label: 'Upload Custom Font (.ttf/.otf)', + type: 'file', + extensions: ['ttf', 'otf', 'woff', 'woff2'], + readtype: 'arraybuffer', + condition: () => true + }, + + // === STYLE === + style_section: { type: 'info', text: '
✨ Text Style
' }, + bold: { + label: 'Bold', + type: 'checkbox', + value: false, + condition: (form) => form.font !== 'default' + }, + italic: { + label: 'Italic', + type: 'checkbox', + value: false, + condition: (form) => form.font !== 'default' && !isInversionFontEntry(form.font) + }, + underline: { + label: 'Underline', + type: 'checkbox', + value: false, + condition: (form) => form.font !== 'default' + }, + strikethrough: { + label: 'Strikethrough', + type: 'checkbox', + value: false, + condition: (form) => form.font !== 'default' + }, + + // === 3D SETTINGS === + depth_section: { type: 'info', text: '
🔲 3D Settings
' }, + depth: { + label: 'Depth (Z size, 0 = flat)', + type: 'number', + value: 1, + min: 0, + max: 16, + step: 1 + }, + outlineEnabled: { + label: 'Enable Outline', + type: 'checkbox', + value: false + }, + outlineColor: { + label: 'Outline Color', + type: 'color', + value: '#000000', + condition: (form) => form.outlineEnabled + }, + outline_perf_info: { + type: 'info', + text: 'Outline adds a colored border around each letter pixel.', + condition: (form) => form.outlineEnabled + }, + optimizeGeometry: { + label: 'Optimize Geometry (recommended)', + type: 'checkbox', + value: true + }, + optimizeGeometry_info: { + type: 'info', + text: 'Extrudes depth into one column per pixel (same colors as before, ~depth× fewer cubes).', + condition: (form) => form.optimizeGeometry + }, + + // === LAYOUT === + layout_section: { type: 'info', text: '
📐 Layout
' }, + letterSpacing: { + label: 'Letter Spacing', + type: 'number', + value: 1, + min: -5, + max: 20, + step: 1 + }, + lineSpacing: { + label: 'Line Spacing', + type: 'number', + value: 3, + min: -5, + max: 20, + step: 1 + }, + wordSpacing: { + label: 'Word Spacing', + type: 'number', + value: 2, + min: -5, + max: 20, + step: 1 + }, + alignment: { + label: 'Alignment', + type: 'select', + options: { + left: 'Left', + center: 'Center', + right: 'Right' + }, + value: 'left' + }, + + // === PRESETS === + preset_section: { type: 'info', text: '
💾 Presets
' }, + preset: { + label: 'Load Preset', + type: 'select', + options: { + '': '-- Select Preset --', + ...Object.keys(DEFAULT_PRESETS).reduce((acc, k) => { + acc[k] = k.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + return acc; + }, {}), + ...Object.keys(userPresets).reduce((acc, k) => { + acc[k] = k + ' (Custom)'; + return acc; + }, {}) + }, + value: '' + }, + savePresetName: { + label: 'Save Current as Preset', + type: 'text', + value: '', + placeholder: 'Enter preset name...' + } + }, + + onFormChange(formData) { + if (formData.preset && formData.preset !== '') { + const preset = DEFAULT_PRESETS[formData.preset] || userPresets[formData.preset]; + if (preset) { + normalizePresetFontFields(preset); + this.setFormValues(preset, false); + this.form.preset.value = ''; + } + } + + if (formData.customFont && formData.customFont.length > 0) { + loadCustomFont(formData.customFont[0], (fontName) => { + const newOptions = getFontOptions(); + this.form.font.options = newOptions; + this.form.font.value = fontName; + Blockbench.showQuickMessage('Custom font loaded: ' + fontName); + }); + } + }, + + onOpen() { + this._colorPickerFixHandler = attachColorPickerHexFix(this); + }, + + onConfirm(formData) { + if (formData.savePresetName && formData.savePresetName.trim() !== '') { + prepareFormColors(formData); + const presetData = { + font: formData.font, + fontSize: formData.fontSize, + depth: formData.depth, + outlineColor: formData.outlineColor, + outlineEnabled: formData.outlineEnabled, + letterSpacing: formData.letterSpacing, + lineSpacing: formData.lineSpacing, + wordSpacing: formData.wordSpacing, + alignment: formData.alignment, + optimizeGeometry: formData.optimizeGeometry, + bold: formData.bold, + italic: formData.italic, + underline: formData.underline, + strikethrough: formData.strikethrough + }; + userPresets[formData.savePresetName] = presetData; + savePresets(); + Blockbench.showQuickMessage('Preset saved: ' + formData.savePresetName); + } + + normalizeFormFontFields(formData); + const editContext = pendingEditContext; + pendingEditContext = null; + if (isDefaultFont(formData.font) || isCustomFont(formData.font)) { + confirmTextGeneration(formData, editContext); + } else { + loadCuratedFont(formData.font).then((ok) => { + if (!ok) { + Blockbench.showMessageBox({ + title: 'Font Load Error', + message: 'Could not load the selected font from the internet. Check your connection or choose Default / upload a custom font.', + buttons: ['OK'] + }); + return; + } + setTimeout(() => confirmTextGeneration(formData, editContext), 100); + }); + } + + detachColorPickerHexFix(this._colorPickerFixHandler); + cleanupOrphanColorPickers(); + this.hide(); + }, + + onCancel() { + detachColorPickerHexFix(this._colorPickerFixHandler); + cleanupOrphanColorPickers(); + pendingEditContext = null; + clearPreview(); + this.hide(); + } + }); + } + + // ============================================ + // ABOUT DIALOG + // ============================================ + function addAboutButton() { + let about = MenuBar.menus.help.structure.find(e => e.id === 'about_plugins'); + + if (!about) { + about = new Action('about_plugins', { + name: 'About Plugins...', + icon: 'info', + children: [] + }); + MenuBar.addAction(about, 'help'); + } + + aboutAction = new Action('about_' + PLUGIN_ID, { + name: 'About ' + PLUGIN_NAME + '...', + icon: PLUGIN_ICON, + click: function() { + showAbout(); + } + }); + + about.children.push(aboutAction); + } + + function showAbout() { + new Dialog({ + id: 'tgp_about', + title: 'About ' + PLUGIN_NAME, + width: 600, + lines: [` +
+

${PLUGIN_NAME}

+

+ Professional 3D text generator for Minecraft models in Blockbench +

+

+ Version ${PLUGIN_VERSION} | by ${PLUGIN_AUTHOR} +

+
+

Features

+ +
+

+ Visit speaway.com for more tools and resources. +

+
+ `], + buttons: ['Close'] + }).show(); + } + + // ============================================ + // PLUGIN BROWSER -- hide empty Settings tab + // Blockbench shows Settings for all installed plugins; we have none. + // ============================================ + function syncPluginBrowserSettingsTab() { + const tabBar = document.querySelector('#plugin_browser_page_tab_bar'); + if (!tabBar) return; + + const settingsTab = Array.from(tabBar.children).find( + li => li.textContent.trim() === 'Settings' + ); + if (!settingsTab) return; + + const hideSettings = typeof Plugin !== 'undefined' && Plugin.selected?.id === PLUGIN_ID; + + settingsTab.style.display = hideSettings ? 'none' : ''; + + if (hideSettings && settingsTab.classList.contains('selected')) { + const aboutTab = Array.from(tabBar.children).find( + li => li.textContent.trim() === 'About' + ); + aboutTab?.click(); + } + } + + function startPluginBrowserTabSync() { + syncPluginBrowserSettingsTab(); + if (pluginBrowserTabSyncHandler) return; + + pluginBrowserTabSyncHandler = () => { + requestAnimationFrame(syncPluginBrowserSettingsTab); + }; + document.addEventListener('click', pluginBrowserTabSyncHandler, true); + } + + function stopPluginBrowserTabSync() { + if (pluginBrowserTabSyncHandler) { + document.removeEventListener('click', pluginBrowserTabSyncHandler, true); + pluginBrowserTabSyncHandler = null; + } + + const tabBar = document.querySelector('#plugin_browser_page_tab_bar'); + const settingsTab = tabBar && Array.from(tabBar.children).find( + li => li.textContent.trim() === 'Settings' + ); + if (settingsTab) settingsTab.style.display = ''; + } + + // ============================================ + // PLUGIN REGISTRATION + // ============================================ + Plugin.register(PLUGIN_ID, { + title: PLUGIN_NAME, + author: PLUGIN_AUTHOR, + description: PLUGIN_DESCRIPTION, + icon: PLUGIN_ICON, + version: PLUGIN_VERSION, + variant: 'both', + min_version: '4.8.0', + tags: ['Minecraft: Java Edition', 'Minecraft: Bedrock Edition', 'Font'], + + onload() { + loadPresets(); + + action = new Action('text_generator', { + name: 'Text Generator', + description: 'Create 3D Minecraft-style text with custom fonts and advanced styling', + icon: PLUGIN_ICON, + category: 'tools', + condition: () => Format.id !== 'image', + click: function() { + dialog = createDialog(); + dialog.show(); + } + }); + + MenuBar.addAction(action, 'tools'); + addAboutButton(); + + // Preset menu + presetMenu = new Menu([ + { + name: 'Minecraft Classic', + icon: 'grass_block', + click: () => { + dialog = createDialog(); + dialog.show(); + setTimeout(() => { + dialog.form.preset.value = 'minecraft_classic'; + dialog.updateFormValues(); + }, 100); + } + }, + { + name: 'Pixel Art Style', + icon: 'auto_fix_high', + click: () => { + dialog = createDialog(); + dialog.show(); + setTimeout(() => { + dialog.form.preset.value = 'pixel_art'; + dialog.updateFormValues(); + }, 100); + } + }, + { + name: 'Modern 3D', + icon: 'view_in_ar', + click: () => { + dialog = createDialog(); + dialog.show(); + setTimeout(() => { + dialog.form.preset.value = 'modern_3d'; + dialog.updateFormValues(); + }, 100); + } + }, + '_', + { + name: 'Custom Presets...', + icon: 'save', + click: () => { + dialog = createDialog(); + dialog.show(); + } + } + ]); + startPluginBrowserTabSync(); + setupOutlinerDoubleClickEdit(); + + editTextGroupAction = new Action('tgp_edit_text_group', { + name: 'Edit with Text Generator', + description: 'Reopen text with saved settings to regenerate', + icon: 'text_fields', + condition: () => { + const sel = getSelectedOutlinerGroup(); + return !!findTextRootGroup(sel); + }, + click: () => { + const root = findTextRootGroup(getSelectedOutlinerGroup()); + if (root) openTextEditorForGroup(root); + } + }); + if (typeof Outliner !== 'undefined' && Outliner.control_menu_group) { + Outliner.control_menu_group.splice(0, 0, editTextGroupAction); + } + }, + + onunload() { + stopPluginBrowserTabSync(); + teardownOutlinerDoubleClickEdit(); + if (editTextGroupAction) { + if (typeof Outliner !== 'undefined' && Outliner.control_menu_group) { + const idx = Outliner.control_menu_group.indexOf(editTextGroupAction); + if (idx !== -1) Outliner.control_menu_group.splice(idx, 1); + } + editTextGroupAction.delete(); + editTextGroupAction = null; + } + MenuBar.removeAction('tools.text_generator'); + MenuBar.removeAction('help.about_plugins.about_' + PLUGIN_ID); + action.delete(); + if (aboutAction) aboutAction.delete(); + clearPreview(); + } + }); + +})();