From a5d5da1dcee2a07df2fd57845a7ee1c45727abf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ozan=20=C4=B0nce?= <126924958+Aegyip@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:45:30 +0300 Subject: [PATCH 1/4] Add Item Generator plugin Spawns Minecraft blocks and items as 3D geometry in Blockbench using live vanilla textures fetched from mcasset.cloud. Supports all block model JSON chains, pixel-extruded flat items, and complete bed models (head + foot in one group). Includes a searchable catalog panel with version picker. --- plugins.json | 20 + plugins/item_generator.js | 1327 +++++++++++++++++++++++++++++++++++++ 2 files changed, 1347 insertions(+) create mode 100644 plugins/item_generator.js diff --git a/plugins.json b/plugins.json index fc512b28..ce9638f4 100644 --- a/plugins.json +++ b/plugins.json @@ -1,4 +1,24 @@ { + "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": "2.4.1", + "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", + "tags": ["Minecraft: Java Edition", "Minecraft: Bedrock Edition", "Generator"] + }, "sam3dj": { "title": "sam3DJ", "author": "1Turtle", diff --git a/plugins/item_generator.js b/plugins/item_generator.js new file mode 100644 index 00000000..db964346 --- /dev/null +++ b/plugins/item_generator.js @@ -0,0 +1,1327 @@ +(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("✓ " + entry.name + " spawned", 1500); + }) + .catch(function (e) { + Blockbench.showQuickMessage("✗ " + 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, + + 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; } + }, + }); +})(); From 1cf025bbe10ce238c6a78a30e7028c57525a364a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ozan=20=C4=B0nce?= <126924958+Aegyip@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:45:55 +0300 Subject: [PATCH 2/4] Add Text Generator plugin Converts any text into Minecraft-style 3D cube geometry. Supports the default Minecraft bitmap font, curated block fonts auto-loaded from Google Fonts, or custom .ttf/.otf upload. Features adjustable size, depth (0 = flat), letter spacing, alignment, colored outline, bold/italic/underline/strikethrough, saveable presets, live preview, and full Unicode/emoji support. --- plugins/text_generator.js | 2374 +++++++++++++++++++++++++++++++++++++ 1 file changed, 2374 insertions(+) create mode 100644 plugins/text_generator.js diff --git a/plugins/text_generator.js b/plugins/text_generator.js new file mode 100644 index 00000000..d0240427 --- /dev/null +++ b/plugins/text_generator.js @@ -0,0 +1,2374 @@ +(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 = '2.4.1'; + 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 + }, + + // === METİN === + 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 + }, + + // === STİL === + 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, + about: '**Text Generator** by [Speaway](https://speaway.com) turns any text into Minecraft-style 3D cube geometry.\n\n## Features\n- Default Minecraft-style + curated block fonts + custom upload\n- Size, depth (0 = paper-thin), spacing, alignment\n- Colored outline border\n- Bold, italic, underline, strikethrough\n- Saveable presets\n- Unicode and emoji support\n- Double-click a Text group in the outliner to edit and regenerate\n\n## How to use\nGo to **Tools → Text Generator**, enter your text, adjust settings, and click Generate.', + 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(); + } + }); + +})(); From 02125b66974ebe077489cec90663f2cd124290c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ozan=20=C4=B0nce?= <126924958+Aegyip@users.noreply.github.com> Date: Sun, 5 Jul 2026 21:55:05 +0300 Subject: [PATCH 3/4] Fix corrupted non-ASCII characters in comments and strings Replace mojibake sequences (box-drawing chars, em dashes, ellipses, middle dots, arrows, checkmarks) with clean ASCII equivalents. Translate remaining Turkish comments to English. --- plugins/item_generator.js | 48 +++++++++++++++++++-------------------- plugins/text_generator.js | 14 ++++++------ 2 files changed, 31 insertions(+), 31 deletions(-) diff --git a/plugins/item_generator.js b/plugins/item_generator.js index db964346..2cd9382a 100644 --- a/plugins/item_generator.js +++ b/plugins/item_generator.js @@ -1,7 +1,7 @@ (function () { "use strict"; - // ─── CSS ────────────────────────────────────────────────────────────────────── + // --- CSS ---------------------------------------------------------------------- const CSS = ` .igp-root { @@ -72,7 +72,7 @@ } `; - // ─── MCAsset Loader ─────────────────────────────────────────────────────────── + // --- MCAsset Loader ----------------------------------------------------------- var MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; var MCASSET_CDN = "https://assets.mcasset.cloud"; @@ -114,7 +114,7 @@ }; } - // ─── Block Face Grouping ────────────────────────────────────────────────────── + // --- Block Face Grouping ------------------------------------------------------ var FACE_SUFFIXES = ["top", "bottom", "north", "south", "east", "west", "side", "front", "back"]; var FACE_TO_BB = { @@ -342,7 +342,7 @@ getError: function () { return catalogError; }, }; - // ─── Texture Cache & Resolver ───────────────────────────────────────────────── + // --- Texture Cache & Resolver ------------------------------------------------- var textureCache = new Map(); var textureCacheOrder = []; @@ -390,7 +390,7 @@ textureCacheOrder.length = 0; } - // ─── Texture Helpers ────────────────────────────────────────────────────────── + // --- Texture Helpers ---------------------------------------------------------- function createAndApplyTexture(cubes, name, hexColor) { try { @@ -442,7 +442,7 @@ }) .catch(function (e) { console.warn("[IGP] Vanilla texture failed, using fallback:", e.message); - Blockbench.showQuickMessage("Texture unavailable — using placeholder", 2000); + Blockbench.showQuickMessage("Texture unavailable -- using placeholder", 2000); createAndApplyTexture(cubes, name, fallbackHex); }); } @@ -470,7 +470,7 @@ 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); + Blockbench.showQuickMessage("Texture unavailable -- using placeholder", 2000); createAndApplyTexture(cubes, label, "#9B9B9B"); }); } @@ -494,7 +494,7 @@ }); } - // ─── Spawn Helpers ──────────────────────────────────────────────────────────── + // --- Spawn Helpers ------------------------------------------------------------ var SPAWN_PIVOT = [-8, 0, -8]; @@ -534,7 +534,7 @@ }); } - // ─── Block Spawns ───────────────────────────────────────────────────────────── + // --- Block Spawns ------------------------------------------------------------- function spawnStandardBlock(label, entry) { label = label || "Block"; @@ -609,7 +609,7 @@ return finishSpawn([c], label, entry); } - // ─── Item Spawns ────────────────────────────────────────────────────────────── + // --- Item Spawns -------------------------------------------------------------- function spawnExtrudedItem(label, entry) { label = label || "Item"; @@ -758,7 +758,7 @@ return finishSpawn([head, shaft], label, entry); } - // ─── Model JSON Loader ──────────────────────────────────────────────────────── + // --- Model JSON Loader -------------------------------------------------------- var modelCache = new Map(); @@ -815,7 +815,7 @@ return MCASSET_CDN + "/" + version + "/assets/minecraft/textures/" + ref.replace(/^minecraft:/, '') + ".png"; } - // ─── Model-Based Spawner ────────────────────────────────────────────────────── + // --- Model-Based Spawner ------------------------------------------------------ function spawnFromModelJson(label, model, version, fallbackEntry) { var textures = model.textures || {}; @@ -1053,7 +1053,7 @@ (headModel.elements && headModel.elements.length); if (!hasElements) { - // No elements in model JSON — fall back to two simple cubes + // 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' }); @@ -1091,7 +1091,7 @@ }); } - // ─── Spawn Router ───────────────────────────────────────────────────────────── + // --- Spawn Router ------------------------------------------------------------- function spawnFromEntry(entry) { var label = entry.name; @@ -1129,7 +1129,7 @@ return spawnStandardBlock(label, entry); } - // ─── Lazy Icon Observer ─────────────────────────────────────────────────────── + // --- Lazy Icon Observer ------------------------------------------------------- var iconObserver = null; @@ -1159,7 +1159,7 @@ }); } - // ─── Plugin ─────────────────────────────────────────────────────────────────── + // --- Plugin ------------------------------------------------------------------- var styleEl = null; var igpPanel = null; @@ -1181,7 +1181,7 @@ ' ', '