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 }}',
+ '
',
+ '
',
+ ' ',
+ ' ',
+ ' ',
+ '
',
+ ' ',
+ '
',
+ '
',
+ '
{{ entry.name }}
',
+ '
',
+ '
No textures found.
',
+ '
',
+ ' ',
+ ' ',
+ '
',
+ ].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
+
+ - Fonts: Default Minecraft-style, curated block fonts, or custom upload
+ - Font Size: 4px to 128px adjustable
+ - 3D Depth: 0 (paper-thin) to 16 cubes deep
+ - Outline: Colored border around text pixels
+ - Text Styles: Bold, Italic, Underline, Strikethrough
+ - Layout: Spacing and alignment controls
+ - Outliner: Text → Letter → Cube hierarchy
+ - Edit: Double-click a Text group to reopen settings
+ - Unicode: Full emoji and special character support
+ - Live Preview: Real-time 3D preview
+ - Presets: Save and load your favorite settings
+
+
+
+ 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 @@
' ',
' ',
' ',
+ ' placeholder="Search blocks & items..." />',
' ',
'