diff --git a/plugins.json b/plugins.json index a6f1651e..a7f87951 100644 --- a/plugins.json +++ b/plugins.json @@ -1534,5 +1534,19 @@ "website": "https://svdex.moe", "min_version": "4.8.0", "has_changelog": true + }, + "sb_worn_display": { + "title": "SB Worn Display Editor", + "author": "hrmcngs", + "icon": "backpack", + "description": "Adds a Custom Slot row to the Display panel so you can edit custom item display keys (Sophisticated Backpacks worn, MAW saya back/belt, Backpack-Arsenal chestplate / placed) visually in the 3D viewport, using the same sliders as the vanilla slots.", + "tags": ["Minecraft: Java Edition", "Modeling"], + "version": "4.13.0", + "min_version": "4.8.0", + "variant": "both", + "website": "https://github.com/hrmcngs/sb-worn-display-blockbench", + "repository": "https://github.com/hrmcngs/sb-worn-display-blockbench", + "bug_tracker": "https://github.com/hrmcngs/sb-worn-display-blockbench/issues", + "creation_date": "2026-05-31" } } diff --git a/plugins/sb_worn_display/LICENSE.MD b/plugins/sb_worn_display/LICENSE.MD new file mode 100644 index 00000000..49625d4c --- /dev/null +++ b/plugins/sb_worn_display/LICENSE.MD @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Hiromichi Nagase + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/sb_worn_display/about.md b/plugins/sb_worn_display/about.md new file mode 100644 index 00000000..ae926b0f --- /dev/null +++ b/plugins/sb_worn_display/about.md @@ -0,0 +1,48 @@ +# SB Worn Display Editor + +Adds a **Custom Slot** row to Blockbench's Display panel so you can edit custom item display keys defined by Forge mods — visually, in the 3D viewport, with the same sliders you use for the vanilla `head` / `gui` / `ground` slots. + +Built specifically for these keys, but trivially extensible to any custom key: + +| Key | Used by | +|---|---| +| `sophisticatedbackpacks:worn` | Sophisticated Backpacks — backpack worn on Curios "back" slot | +| `the_four_primitives_and_weapons:back` | MAW saya worn on Curios "back" slot | +| `the_four_primitives_and_weapons:belt` | MAW saya worn on Curios "belt" slot | + +## Why + +Blockbench ships with the 8 vanilla Java display contexts (`thirdperson_*`, `firstperson_*`, `head`, `gui`, `ground`, `fixed`). Custom display contexts defined by Forge mods via `ItemDisplayContext.create(...)` are preserved in the model JSON, but the default UI gives you no way to edit them visually — and worse, **values for unknown keys are silently dropped on save** because the exporter iterates `DisplayMode.slots`. + +This plugin: + +1. **Registers the custom keys** in `DisplayMode.slots` so values round-trip safely through save / reload. +2. **Adds a "Custom Slot" row** under Reference Model using Blockbench's own native UI markup (`panel_toolbar_label` + `bar tabs_small icon_bar` + `label.tool`) — visually identical in style to the vanilla Slot row. +3. Provides a fallback **numeric Edit dialog** under the Tools menu for typing exact values. + +## Usage + +1. Open a Java Item Model JSON +2. Switch to **Display** mode +3. In the right panel, find the new **Custom Slot** row under Reference Model +4. Click any of the three icons (backpack / ruler / belt) +5. Adjust rotation, translation, and scale with the standard sliders +6. Ctrl+S to save + +The plugin uses `head`'s camera angle and player reference model as a visual proxy. **Scale and rotation translate accurately** to in-game appearance. **Translation values** are pixel-relative offsets from the anchor point set by the mod's renderer (back / belt), which is not the head — so iterate by testing in-game for translation tuning. + +## Adding more keys + +Source the plugin from its [GitHub repo](https://github.com/hrmcngs/sb-worn-display-blockbench) and edit the `TARGETS` array at the top of `sb_worn_display.js`. + +## How it works + +The Display panel's Slot row in Blockbench's `DisplayModePanel.vue` is hardcoded (no `v-for` loop), so there is no official extension API. This plugin works around that by injecting a new section into the panel DOM and using a MutationObserver to keep it present after Vue re-renders. Click handlers reuse `DisplayMode.loadHead()` to set up the camera and reference model, then override `DisplayMode.slot` to the custom key. + +This approach is inherently fragile — a future Blockbench update that restructures `DisplayModePanel.vue` will break the injected row. The Tools menu Edit dialogs remain as a stable fallback. + +## Source + +- Repository: +- Issues: +- License: MIT diff --git a/plugins/sb_worn_display/members.yml b/plugins/sb_worn_display/members.yml new file mode 100644 index 00000000..39406e70 --- /dev/null +++ b/plugins/sb_worn_display/members.yml @@ -0,0 +1,2 @@ +maintainers: + - hrmcngs diff --git a/plugins/sb_worn_display/sb_worn_display.js b/plugins/sb_worn_display/sb_worn_display.js new file mode 100644 index 00000000..fb48019e --- /dev/null +++ b/plugins/sb_worn_display/sb_worn_display.js @@ -0,0 +1,1498 @@ +/** + * SB Worn Display Editor — Custom Display Reference Editor for Blockbench + * + * Display パネル内、Reference Model の下に "Custom Slot" 行を追加し、 + * 本体の Slot 行と同じ見た目 (panel_toolbar_label + tabs_small icon_bar + + * label.tool + material-icons) でカスタム display key を選択・編集できる + * ようにする。スライダーは本体の Display パネルそのままを流用。 + * + * カスタム display key: + * - sophisticatedbackpacks:worn (SB の Curios back 装備時) + * - the_four_primitives_and_weapons:back (MAW saya の Curios back 装備時) + * - the_four_primitives_and_weapons:belt (MAW saya の Curios belt 装備時) + * - backpack_arsenal:chestplate (Backpack-Arsenal の胸甲スタイル装着時) + * + * 仕組み: + * 1. displayReferenceObjects.slots (= DisplayMode.slots) に key を push + * → JSON 保存/読込で必須 + * 2. Display パネルの DOM に Blockbench 標準書式の "Custom Slot" 行を注入 + * ─ label の class / icon_bar 構造は本体と同一なので追加 CSS 不要 + * ─ Reference Model の下に出るので折り返しに埋もれない + * 3. ボタン click 時は DisplayMode.loadHead() を踏み台にカメラ/Reference + * バーをセットアップ、その後 DisplayMode.slot をカスタムキーに上書き + * 4. MutationObserver で Vue 再レンダ時も自動再注入 + * + * Author: hrmcngs + * Source: https://github.com/hrmcngs/sb-worn-display-blockbench + * License: MIT + */ +(function () { + const PLUGIN_ID = 'sb_worn_display'; + + // anchorY: Display プレビューで co (display_area) を置く Y 座標。 + // loadHead() のデフォルトは y=28 (顔の中心) だが、custom slot ごとに + // 別のリファレンス点に動かしたい場合 anchorY で上書きする。 + // プレイヤーリファレンス (32px tall Steve) の基準: + // y=28: 顔の中心 + // y=24: 首・頭の付け根 (head bone pivot) + // y=18: 胸・腕の中心 + // y=12: 腰・ベルト位置 (body bone pivot in Minecraft) + // y= 8: 太もも + // y= 0: 足元 + // syncGroup: 同じ値 (rotation / translation / scale) を共有させたいスロット + // を同じ文字列でグルーピングする。例えば 'back' を付けた slot は、 + // どれかが編集されると 200ms 以内に他の同 group メンバーに値が伝播する。 + // 背中装着の SB worn と MAW back saya は同じ位置なので同期させる。 + // + // preview transform (display_area の Three.js Object3D を上書きするための値): + // anchorX / anchorY / anchorZ : preview 上の display_area の position + // - 指定しない軸は 0 にリセット (前 slot の値の残留を防ぐ) + // - anchorY だけ指定すれば「水平 (X/Z) は body center、垂直は anchorY」 + // previewRotationX / previewRotationY / previewRotationZ : display_area の回転 (rad) + // - in-game レンダラ (SB / MAW / armor 等) が JSON display 値の前に独自の + // 回転を被せてくる場合があるので、preview でそれを模倣して合わせる + // - 例: SB の BackpackLayerRenderer は内部で Y-180° flip しているので + // SB worn 系の slot は previewRotationY = Math.PI を指定 + // - 例: MAW saya は LivingEntityRenderer の scale(-1,-1,1) 経由で Z-180° + // 相当の flip があるので previewRotationZ = Math.PI + // - 指定無しの軸は 0 にリセット (前 slot からの残留防止) + // previewScale : display_area の uniform scale + // - loadHead 直後は 0.625 (head 用) になっているので、フルサイズの + // backpack を player ボディに重ねたいときは 1.0 で上書き + // - 指定しないと loadHead の 0.625 が残る (saya は今までそれで OK だった) + const TARGETS = [ + { + key: 'sophisticatedbackpacks:worn', + tooltip: 'SB Worn (背中・SB) — sophisticatedbackpacks:worn', + icon: 'backpack', + // SB の BackpackLayerRenderer.translateRotateAndScale をそのまま再現: + // mulPose(YP.180) + mulPose(ZP.180) + translate(0, -0.25, -0.3) + // + 外側 LivingEntityRenderer の translate(0,1.5,0) + scale(-1,-1,1) も合成 + // = vertex (x,y,z) → (-x, y+20, -z+4.8) ピクセル単位 + // = Y軸 180° 回転 + translate(0, 20, 4.8) と等価 + // これで preview がプレイヤーの胸・上部体幹に正しく embed され、 + // in-game の SB worn と同じ位置・向きになる。 + anchorY: 20, // 胸上部 (SB の合成 Y オフセット) + anchorZ: 4.8, // 前方への合成 Z オフセット + previewScale: 1.0, // SB は内部 scale 無し + previewRotationY: Math.PI, // SB 合成回転 = Y-180 のみ + // previewRotationZ 不要 (SB の Z-180 は LivingEntityRenderer scale flip と相殺) + // syncGroup なし — 各 slot 独立 (saya / backpack / chestplate は別 renderer) + }, + { + key: 'the_four_primitives_and_weapons:back', + tooltip: 'MAW Saya Back (背中・MAW鞘) — the_four_primitives_and_weapons:back', + icon: 'straighten', + // MAW の ScabbardCurioRenderer (back slot) をそのまま再現: + // body.translateAndRotate + translate(0, 0.375, 0) blocks + scale(2/3) + // + 外側 LivingEntityRenderer の translate(0, 1.5, 0) + scale(-1,-1,1) + // = vertex (x,y,z) → (-2x/3, 18-2y/3, 2z/3) ピクセル単位 + // = Z軸 180° 回転 + scale 2/3 + translate(0, 18, 0) と等価 + anchorY: 18, // 体幹の中央 (合成 Y オフセット) + anchorZ: 0, + previewScale: 2 / 3, // MAW renderer の scale(2/3, 2/3, 2/3) + previewRotationZ: Math.PI, // X+Y flip (= Z 軸 180° 回転) を再現 + // syncGroup なし — 各 slot 独立 (saya / backpack / chestplate は別 renderer) + }, + { + key: 'the_four_primitives_and_weapons:belt', + tooltip: 'MAW Saya Belt (ベルト・MAW鞘) — the_four_primitives_and_weapons:belt', + icon: 'linear_scale', + // MAW の ScabbardCurioRenderer (belt slot) をそのまま再現: + // body.translateAndRotate + translate(0, 0.75, 0) blocks + scale(2/3) + // + 外側 LivingEntityRenderer の translate(0, 1.5, 0) + scale(-1,-1,1) + // = vertex (x,y,z) → (-2x/3, 12-2y/3, 2z/3) ピクセル単位 + // = Z軸 180° 回転 + scale 2/3 + translate(0, 12, 0) と等価 + anchorY: 12, // 腰・ベルト位置 (合成 Y オフセット) + anchorZ: 0, + previewScale: 2 / 3, // MAW renderer の scale(2/3, 2/3, 2/3) + previewRotationZ: Math.PI, // X+Y flip (= Z 軸 180° 回転) を再現 + }, + { + // Backpack-Arsenal のカスタムバックパックを「胸甲 (chestplate) スタイル」で + // 装着・描画する用の display context。mod 側で + // ItemDisplayContext.create("backpack_arsenal:chestplate", ...) + // を登録し、armor / curios chest スロット描画時にこの context を指定して + // baked model の applyTransform を呼べば、ここで編集した値が反映される。 + key: 'backpack_arsenal:chestplate', + tooltip: 'Backpack Arsenal Chestplate (胸甲・カスタムバックパック) — backpack_arsenal:chestplate', + icon: 'shield', + // BA は SB の BackpackCurioRenderer を流用しているので、SB worn と + // 完全に同じ transform を再現 (SB のソース translateRotateAndScale から計算済) + anchorY: 20, + anchorZ: 4.8, + previewScale: 1.0, + previewRotationY: Math.PI, + // syncGroup なし — 各 slot 独立 (saya / backpack / chestplate は別 renderer) + }, + { + // Backpack-Arsenal のバックパックを「ワールドにブロックとして設置」した時の + // 描画用 display context。SB の BackpackBlockEntityRenderer は FIXED を + // 使うが、 BA は自前 BER (ArsenalBackpackBlockEntityRenderer) で + // ItemDisplayContext.create("backpack_arsenal:placed", ..., FIXED) + // を指定して描画する。 fallback が FIXED なので JSON に display.placed が + // 無ければ FIXED と同じ見た目になる。 + // + // BER の transform は translate(0.5, 0, 0.5) + Y軸 facing 回転のみで、 + // 内部 scale や rotation の追加は無し (= 純粋に display 値だけが効く)。 + // よって preview transform は identity 相当でよい。 + // + // referenceMode: 'fixed' でプレイヤー reference を消し、アイテムフレーム風の + // ベースに切り替える。 装着系 (worn / back / belt / chestplate) は身体と + // 重ねて見たいので loadHead のままだが、 設置ブロックは player と無関係なので + // プレイヤーが映ると邪魔になる。 + key: 'backpack_arsenal:placed', + tooltip: 'Backpack Arsenal Placed Block (設置ブロック) — backpack_arsenal:placed', + icon: 'view_in_ar', // 3D box icon (block らしさ) + referenceMode: 'placed_block', // 'placed_block' = loadFixed + 額縁 hide + ブロック guide + // (床平面 + 中心マーカー + 16x16x16 輪郭) + // 床位置とブロック中心が一目でわかるプレビュー + // anchor / previewScale / previewRotation は未指定 (= loadFixed のデフォルト + // item frame ライクな表示をそのまま使う) + // syncGroup なし — 設置ブロックは独立 (装着系とは別 transform 系統) + }, + ]; + + const REF_BAR_ID = 'display_ref_bar'; + const CUSTOM_BAR_ID = 'sb_custom_display_bar'; + const CUSTOM_LABEL_ID = 'sb_custom_display_label'; + const INJECTED_ATTR = 'data-sb-custom-slot'; + + const actions = []; + let observer = null; + let modeListener = null; + + function safeId(key) { + return 'sbcd_' + key.replace(/[^a-z0-9]/gi, '_').toLowerCase(); + } + + function getProject() { + return (typeof Project !== 'undefined') ? Project : null; + } + + // ─── syncGroup: cross-slot value mirroring ───────────────────────── + // 同じ syncGroup を持つ TARGETS は rotation/translation/scale を共有する。 + // 編集中スロットを 200ms 周期で polling、変更があれば peer slot にコピー。 + // 切替時には「非デフォルトの値を持つ peer」を canonical として採用し、 + // 全 group メンバーに伝播してから polling 開始。 + + let syncTimer = null; + let syncLastSnap = null; + let syncActiveKey = null; + + function snapshotSlotData(slot) { + if (!slot) return ''; + return JSON.stringify({ + r: (slot.rotation || []).slice(), + t: (slot.translation || []).slice(), + s: (slot.scale || []).slice(), + }); + } + + function isDefaultSlot(slot) { + if (!slot) return true; + const eq = (a, def) => Array.isArray(a) && a.length === def.length + && a.every((v, i) => v === def[i]); + return eq(slot.rotation, [0, 0, 0]) + && eq(slot.translation, [0, 0, 0]) + && eq(slot.scale, [1, 1, 1]); + } + + function applySlotDataTo(key, source) { + if (!source) return; + const p = getProject(); + if (!p) return; + if (!p.display_settings) p.display_settings = {}; + if (!p.display_settings[key]) { + p.display_settings[key] = new DisplaySlot(key); + } + const dst = p.display_settings[key]; + if (Array.isArray(source.rotation)) dst.rotation = source.rotation.slice(); + if (Array.isArray(source.translation)) dst.translation = source.translation.slice(); + if (Array.isArray(source.scale)) dst.scale = source.scale.slice(); + } + + function propagateToSyncPeers(target) { + if (!target || !target.syncGroup) return; + const p = getProject(); + if (!p || !p.display_settings) return; + const src = p.display_settings[target.key]; + if (!src) return; + TARGETS + .filter((t) => t.syncGroup === target.syncGroup && t.key !== target.key) + .forEach((peer) => applySlotDataTo(peer.key, src)); + } + + // syncGroup 内で「canonical」(基準にすべき) slot を選ぶ: + // 1. 引数 preferKey の slot が非デフォルト値を持つならそれ + // 2. 同 group の他 peer のうち非デフォルト値を持つ最初のもの + // 3. 全部デフォルトなら preferKey + function pickCanonicalInGroup(syncGroup, preferKey) { + const p = getProject(); + if (!p || !p.display_settings) return preferKey; + const peers = TARGETS.filter((t) => t.syncGroup === syncGroup); + const preferred = peers.find((t) => t.key === preferKey); + if (preferred) { + const d = p.display_settings[preferred.key]; + if (d && !isDefaultSlot(d)) return preferred.key; + } + for (const t of peers) { + const d = p.display_settings[t.key]; + if (d && !isDefaultSlot(d)) return t.key; + } + return preferKey; + } + + function stopSync() { + if (syncTimer) { + clearInterval(syncTimer); + syncTimer = null; + } + syncLastSnap = null; + syncActiveKey = null; + } + + function startSyncFor(target) { + stopSync(); + if (!target || !target.syncGroup) return; + const peers = TARGETS.filter((t) => t.syncGroup === target.syncGroup && t.key !== target.key); + if (peers.length === 0) return; + syncActiveKey = target.key; + const p = getProject(); + if (!p || !p.display_settings) return; + syncLastSnap = snapshotSlotData(p.display_settings[target.key]); + + syncTimer = setInterval(() => { + try { + // アクティブな slot が他に切り替わってたら polling 終了 + if (typeof DisplayMode === 'undefined' + || DisplayMode.display_slot !== syncActiveKey) { + stopSync(); + return; + } + const cp = getProject(); + if (!cp || !cp.display_settings) return; + const cur = cp.display_settings[syncActiveKey]; + if (!cur) return; + const snap = snapshotSlotData(cur); + if (snap === syncLastSnap) return; + syncLastSnap = snap; + propagateToSyncPeers(target); + } catch (e) { + console.warn('[' + PLUGIN_ID + '] sync poll failed', e); + } + }, 200); + } + + // target.referenceMode に応じて DisplayMode の reference loader を選ぶ。 + // 'fixed' → loadFixed + 額縁 mesh を非表示 (素のモデルだけ見たい placed block 用) + // 'ground' → loadGround (ドロップ品風) + // 'gui' → loadGUI (GUI 表示) + // 'block' → loadFixed + 自作の "床ブロック" reference (上記とは別の世界感プレビュー) + // 未指定 → loadHead (デフォルト、プレイヤーの体に重ねて見る装着系用) + // 指定があっても該当 loader が Blockbench に存在しない場合は loadHead に + // フォールバック (古い Blockbench でもクラッシュしない)。 + function pickReferenceLoader(target) { + const mode = target && target.referenceMode; + if (mode === 'fixed' && typeof DisplayMode.loadFixed === 'function') { + // loadFixed は item frame mesh を画面に出してしまうので、 frame だけ + // 隠してモデル単体プレビューにする (placed block の見た目を素で見たい用)。 + return function () { + DisplayMode.loadFixed(); + hideReferenceSiblingsExceptDisplayArea(); + }; + } + if (mode === 'placed_block' && typeof DisplayMode.loadFixed === 'function') { + // 'fixed' + ブロック guide。 床平面 + 中心マーカー + 16x16x16 輪郭で + // 「ブロックとして置かれた時の足元 / 中心 / 占有範囲」が一目でわかる。 + return function () { + DisplayMode.loadFixed(); + hideReferenceSiblingsExceptDisplayArea(); + addBlockGuide(); + }; + } + if (mode === 'ground' && typeof DisplayMode.loadGround === 'function') return DisplayMode.loadGround; + if (mode === 'gui' && typeof DisplayMode.loadGUI === 'function') return DisplayMode.loadGUI; + if (mode === 'block') { + // ベース: loadFixed → frame mesh hide → 床ブロック追加 (旧版) + return function () { + if (typeof DisplayMode.loadFixed === 'function') { + DisplayMode.loadFixed(); + hideReferenceSiblingsExceptDisplayArea(); + } else { + DisplayMode.loadHead(); + } + addBlockFloorReference(); + }; + } + return DisplayMode.loadHead; + } + + // ─── reference sibling hider ────────────────────────────────────── + // loadFixed が display_area の親シーンに追加する item frame mesh 等を hide する。 + // display_area 自身 (= 編集モデル) は触らない。 + // 元の visible は {@link hiddenSiblings} に退避し、 次の slot 切替時に + // {@link restoreHiddenSiblings} で復元する。 + + let hiddenSiblings = []; + + function hideReferenceSiblingsExceptDisplayArea() { + try { + const da = (typeof display_area !== 'undefined') ? display_area + : (DisplayMode.display_area || DisplayMode.display_base || null); + if (!da || !da.parent || !Array.isArray(da.parent.children)) return; + da.parent.children.forEach((child) => { + if (child !== da && child.visible !== false) { + hiddenSiblings.push({ obj: child, prev: child.visible }); + child.visible = false; + } + }); + } catch (e) { + console.warn('[' + PLUGIN_ID + '] hideReferenceSiblings failed', e); + } + } + + function restoreHiddenSiblings() { + try { + hiddenSiblings.forEach(({ obj, prev }) => { + if (obj) obj.visible = prev !== false; + }); + } catch (e) { + console.warn('[' + PLUGIN_ID + '] restoreHiddenSiblings failed', e); + } + hiddenSiblings = []; + } + + // ─── block guide (placed_block 用) ───────────────────────────────── + // MC で 1 ブロックは model 座標 0..16 を占める。 設置ブロック表示の編集中、 + // 「ブロックが置かれる床」「床の中心(モデルがどこを基準に立つか)」と + // 「MC ワールド座標系の軸方向」 を視覚化する。 + // + // 1. 床平面 : y=0 に 16x16 の半透明 plane (青系) — 設置時の足元の高さ + // 2. 中心マーカー: (8, 0, 8) に小さな赤球 — 床の中心 (= 設置時の基準点) + // 3. XYZ 軸 : 床の中心から各 +8 unit に伸びる色付き矢印 + // Red = +X (East), Green = +Y (Up), Blue = +Z (South) + // MC convention に合わせる。 + // + // 全部 1 つの Group に入れて名前で識別 / 一括削除する。 + const BLOCK_GUIDE_NAME = 'sb_custom_block_guide'; + + function addBlockGuide() { + if (typeof THREE === 'undefined') return; + try { + const scene = getDisplayScene(); + if (!scene || !scene.add) return; + removeBlockGuide(); // 二重追加防止 + + const guide = new THREE.Group(); + guide.name = BLOCK_GUIDE_NAME; + + // (1) 床平面 — y=0 で 16x16 + const floorGeo = new THREE.PlaneGeometry(16, 16); + const floorMat = new THREE.MeshBasicMaterial({ + color: 0x4488ff, + transparent: true, + opacity: 0.18, + side: THREE.DoubleSide, + depthWrite: false, // モデル裏にあっても見える + }); + const floor = new THREE.Mesh(floorGeo, floorMat); + floor.rotation.x = -Math.PI / 2; // XY 平面 → XZ 平面 (水平) + floor.position.set(8, 0, 8); // ブロック中心 X/Z, 高さ 0 + guide.add(floor); + + // (2) 中心マーカー — 床の中心 (8, 0, 8) に半径 0.4 の赤い球 + // 「ブロック中心 (8,8,8)」 ではなく 「床に面した中心」 を示すので y=0。 + const centerGeo = new THREE.SphereGeometry(0.4, 12, 8); + const centerMat = new THREE.MeshBasicMaterial({ + color: 0xff3344, + transparent: true, + opacity: 0.9, + depthWrite: false, + }); + const centerMarker = new THREE.Mesh(centerGeo, centerMat); + centerMarker.position.set(8, 0, 8); + guide.add(centerMarker); + + // (3) XYZ 軸 — 床の中心から +8 unit (= 0.5 block) の矢印 3本 + // +X = East (赤) + // +Y = Up (緑) + // +Z = South (青) + // Three.js の ArrowHelper は (dir, origin, length, color, headLength, headWidth)。 + // origin は (8, 0, 8) 床中心。 length=8 で半ブロック分。 + const axisOrigin = new THREE.Vector3(8, 0, 8); + const axisLength = 8; + const headLen = 1.6; + const headWid = 1.0; + + const xArrow = new THREE.ArrowHelper( + new THREE.Vector3(1, 0, 0), axisOrigin, axisLength, + 0xff4444, headLen, headWid); + const yArrow = new THREE.ArrowHelper( + new THREE.Vector3(0, 1, 0), axisOrigin, axisLength, + 0x44dd44, headLen, headWid); + const zArrow = new THREE.ArrowHelper( + new THREE.Vector3(0, 0, 1), axisOrigin, axisLength, + 0x4488ff, headLen, headWid); + + // ArrowHelper の構成要素 (line + cone) の material を depthWrite=false に + // して、 モデル裏に隠れた軸も薄く透けて見えるようにする。 + [xArrow, yArrow, zArrow].forEach((arrow) => { + if (arrow.line && arrow.line.material) { + arrow.line.material.depthWrite = false; + arrow.line.material.transparent = true; + arrow.line.material.opacity = 0.95; + } + if (arrow.cone && arrow.cone.material) { + arrow.cone.material.depthWrite = false; + arrow.cone.material.transparent = true; + arrow.cone.material.opacity = 0.95; + } + }); + + guide.add(xArrow); + guide.add(yArrow); + guide.add(zArrow); + + scene.add(guide); + } catch (e) { + console.warn('[' + PLUGIN_ID + '] addBlockGuide failed', e); + } + } + + function removeBlockGuide() { + try { + const scene = getDisplayScene(); + if (!scene) return; + const existing = scene.getObjectByName + ? scene.getObjectByName(BLOCK_GUIDE_NAME) + : null; + if (!existing) return; + if (scene.remove) scene.remove(existing); + existing.traverse && existing.traverse((obj) => { + if (obj.geometry && obj.geometry.dispose) obj.geometry.dispose(); + if (obj.material && obj.material.dispose) obj.material.dispose(); + }); + } catch (e) { + console.warn('[' + PLUGIN_ID + '] removeBlockGuide failed', e); + } + } + + // ─── block floor reference (custom) ──────────────────────────────── + // Blockbench に「ワールドに置かれたブロックの足元」reference は無いので、 + // 単純な土色 cube を display_area の親シーンに足す。 + // 名前で識別して二重追加・他 slot 残留を防ぐ。 + const BLOCK_FLOOR_NAME = 'sb_custom_block_floor'; + + function getDisplayScene() { + const da = (typeof display_area !== 'undefined') ? display_area + : (DisplayMode.display_area || DisplayMode.display_base || null); + if (!da) return null; + // display_area の親 (= 表示シーン直下) に追加する。 + // da.parent が無い場合 (loader 前) はそのまま da を返す (later add される)。 + return da.parent || da; + } + + function removeBlockFloorReference() { + try { + const scene = getDisplayScene(); + if (!scene) return; + const existing = scene.getObjectByName + ? scene.getObjectByName(BLOCK_FLOOR_NAME) + : null; + if (existing) { + if (scene.remove) scene.remove(existing); + if (existing.geometry && existing.geometry.dispose) existing.geometry.dispose(); + if (existing.material && existing.material.dispose) existing.material.dispose(); + } + } catch (e) { + console.warn('[' + PLUGIN_ID + '] removeBlockFloorReference failed', e); + } + } + + function addBlockFloorReference() { + if (typeof THREE === 'undefined') return; + try { + const scene = getDisplayScene(); + if (!scene || !scene.add) return; + // 既存があれば一旦消す (slot 切替時に loadCustomSlot からも remove するが念のため)。 + removeBlockFloorReference(); + + // モデル座標 (0..16) に対して床: y=-1 で 16x1x16 の cube。 + // grass topっぽい緑緑灰 / dirt 系の褐色を反映した dual-material はやり過ぎなので + // 単色 (土・草の中間色) で十分。 + const geo = new THREE.BoxGeometry(16, 1, 16); + const mat = new THREE.MeshLambertMaterial({ color: 0x7da34d, transparent: false }); + const mesh = new THREE.Mesh(geo, mat); + mesh.position.set(8, -0.5, 8); // モデル中央 (8,_,8) に合わせて足元 y=-0.5 + mesh.name = BLOCK_FLOOR_NAME; + scene.add(mesh); + } catch (e) { + console.warn('[' + PLUGIN_ID + '] addBlockFloorReference failed', e); + } + } + + // ─── custom slot loader ──────────────────────────────────────────── + // pickReferenceLoader() を踏み台にカメラ/Reference バーを設定し + // その後 slot をカスタムキーに差し替える。 + function loadCustomSlot(target, options) { + // options.silent : true なら通知メッセージを出さない + // options.autoEnterDisplay : false なら Display モードへの自動切替を抑止 + // options.skipCameraReset : true なら DisplayMode.loadHead() をスキップ + // (タブ切替時に視点が暴れるのを防ぐ) + const opts = options || {}; + const p = getProject(); + if (!p) { + if (!opts.silent) Blockbench.showQuickMessage('モデルを開いてください', 1500); + return; + } + if (typeof DisplayMode === 'undefined' || !DisplayMode.loadHead) { + if (!opts.silent) Blockbench.showQuickMessage('DisplayMode が利用できません', 1500); + return; + } + + if (!p.display_settings) p.display_settings = {}; + if (!p.display_settings[target.key]) { + p.display_settings[target.key] = new DisplaySlot(target.key); + } + + if (opts.autoEnterDisplay !== false) { + try { + if (typeof Modes !== 'undefined' && Modes.options && Modes.options.display && !Modes.display) { + Modes.options.display.select(); + } + } catch (e) { } + } + + // slot 切替の度に、前 slot が残した自作 reference (床ブロック等) を確実に消す。 + // referenceMode === 'block' の slot に切り替わる場合は pickReferenceLoader + // 内で再度 add される。それ以外の slot に切り替わる場合はこれで消えたまま。 + removeBlockFloorReference(); + removeBlockGuide(); + // 前 slot で hide した item frame / player などの reference を一旦復元する。 + // referenceMode === 'fixed' / 'placed_block' の slot に切り替わる場合は + // pickReferenceLoader 内で再度 hide される。 + restoreHiddenSiblings(); + + if (!opts.skipCameraReset) { + try { + pickReferenceLoader(target)(); + } catch (e) { + console.warn('[' + PLUGIN_ID + '] reference loader failed', e); + } + } + + DisplayMode.display_slot = target.key; + DisplayMode.slot = p.display_settings[target.key]; + if (DisplayMode.vue && DisplayMode.vue._data) { + DisplayMode.vue._data.slot = p.display_settings[target.key]; + } + + try { DisplayMode.updateDisplayBase(); } catch (e) { } + + // custom slot 固有の preview transform を適用。 + // + // loadHead 直後の display_area の状態: + // position = (0, ~28, ~0) ← 顔の前 + // rotation = (0, 0, 0) + // scale = (0.625, 0.625, 0.625) ← head 用の縮小 + // + // 我々の override: + // anchorX/Y/Z → position 上書き (指定の無い軸はリセットせず loadHead 値を維持) + // ※ anchorY を指定した slot は X/Z も 0 に下書きされる + // (body center に置きたい == 「めり込む」感じ) + // previewRotationZ → rotation.z 上書き (未指定なら 0 にリセット — saya の π が + // 次の slot に残るのを防ぐ) + // previewScale → scale を uniform で上書き (未指定なら loadHead 値継承) + // + // display_area は Blockbench 本体ではモジュールスコープのグローバル変数 + // として定義されているので window.display_area から取る。念のため + // DisplayMode.display_area / DisplayMode.display_base もフォールバック。 + const hasAnyOverride = + typeof target.anchorY === 'number' + || typeof target.anchorX === 'number' + || typeof target.anchorZ === 'number' + || typeof target.previewRotationX === 'number' + || typeof target.previewRotationY === 'number' + || typeof target.previewRotationZ === 'number' + || typeof target.previewScale === 'number'; + if (hasAnyOverride) { + const da = (typeof display_area !== 'undefined') ? display_area + : (DisplayMode.display_area || DisplayMode.display_base || null); + if (da && da.position) { + try { + // anchorY を指定した slot は X/Z も 0 = body center にデフォルト + // 寄せる (個別に anchorX / anchorZ で上書き可能)。 + if (typeof target.anchorY === 'number') { + da.position.x = (typeof target.anchorX === 'number') ? target.anchorX : 0; + da.position.y = target.anchorY; + da.position.z = (typeof target.anchorZ === 'number') ? target.anchorZ : 0; + } else { + if (typeof target.anchorX === 'number') da.position.x = target.anchorX; + if (typeof target.anchorZ === 'number') da.position.z = target.anchorZ; + } + if (da.rotation) { + // 各軸とも未指定なら 0 にリセット (前 slot の残留防止) + da.rotation.x = (typeof target.previewRotationX === 'number') + ? target.previewRotationX : 0; + da.rotation.y = (typeof target.previewRotationY === 'number') + ? target.previewRotationY : 0; + da.rotation.z = (typeof target.previewRotationZ === 'number') + ? target.previewRotationZ : 0; + } + if (typeof target.previewScale === 'number' && da.scale && da.scale.set) { + da.scale.set(target.previewScale, target.previewScale, target.previewScale); + } + if (typeof da.updateMatrixWorld === 'function') da.updateMatrixWorld(); + if (typeof Transformer !== 'undefined' && Transformer.center) Transformer.center(); + } catch (e) { + console.warn('[' + PLUGIN_ID + '] preview transform apply failed', e); + } + } + } + + // syncGroup 設定があれば、まず canonical を pick して値を揃え、 + // その後 polling 開始 (アクティブ slot の編集が peer に伝播する)。 + if (target.syncGroup) { + const canonicalKey = pickCanonicalInGroup(target.syncGroup, target.key); + if (canonicalKey && canonicalKey !== target.key && p.display_settings[canonicalKey]) { + applySlotDataTo(target.key, p.display_settings[canonicalKey]); + DisplayMode.slot = p.display_settings[target.key]; + if (DisplayMode.vue && DisplayMode.vue._data) { + DisplayMode.vue._data.slot = p.display_settings[target.key]; + } + try { DisplayMode.updateDisplayBase(); } catch (e) { } + } + propagateToSyncPeers(target); + startSyncFor(target); + } else { + stopSync(); + } + + try { if (DisplayMode.vue && DisplayMode.vue.$forceUpdate) DisplayMode.vue.$forceUpdate(); } catch (e) { } + + // radio の checked 状態を同期 (Blockbench 本体の :checked ハイライトに乗る) + const radio = document.getElementById(safeId(target.key)); + if (radio) radio.checked = true; + } + + // タブ切替 (select_project) 用: 旧プロジェクトの slot オブジェクトを + // 参照したまま残ると updateDisplayBase が古い値を読みに行き、モデルが + // 変な位置に飛ぶ。新プロジェクトの display_settings に再バインドする。 + // + // 過去版 (v4.2.2) では loadHead をスキップしてカメラリセット回避をして + // いたが、loadHead は同時に Reference Model バーの再ポピュレートも + // やるため、スキップすると人型モデル (player 等) が消える副作用が出た。 + // 現実装: loadHead は呼ぶが、その前後でカメラ位置を save/restore して + // ユーザーの視点だけは維持する。 + function rebindActiveCustomSlot() { + if (typeof DisplayMode === 'undefined') return; + if (typeof Modes === 'undefined' || !Modes.display) return; + const target = TARGETS.find((t) => t.key === DisplayMode.display_slot); + if (!target) return; + + // カメラ状態を一時退避 + let savedPos = null, savedTarget = null; + try { + if (typeof display_preview !== 'undefined' + && display_preview.camPers && display_preview.controls) { + savedPos = display_preview.camPers.position.toArray(); + savedTarget = display_preview.controls.target.toArray(); + } + } catch (e) { } + + // フル再セットアップ (slot + Vue + Reference Model バー) + loadCustomSlot(target, { + silent: true, + autoEnterDisplay: false, + skipCameraReset: false, + }); + + // カメラ視点だけ復元 + try { + if (savedPos && savedTarget + && typeof display_preview !== 'undefined' + && display_preview.camPers && display_preview.controls) { + display_preview.camPers.position.fromArray(savedPos); + display_preview.controls.target.fromArray(savedTarget); + if (display_preview.controls.update) display_preview.controls.update(); + if (display_preview.render) display_preview.render(); + } + } catch (e) { } + } + + // ─── DOM injection: standard Blockbench slot-row format ──────────── + // 本体の Display パネル DisplayModePanel.vue と同じ書式: + //

+ //
+ // + // + // … + //
+ + function buildCustomBar() { + // 本体の "Rotation" 横にある reset アイコン (.tool.head_right) と同じ + // パターン:
で + // panel_toolbar_label と head_right ボタンを横並びにする。 + const label = document.createElement('div'); + label.id = CUSTOM_LABEL_ID; + label.className = 'bar display_slot_section_bar'; + label.setAttribute(INJECTED_ATTR, 'label'); + + const labelText = document.createElement('p'); + labelText.className = 'panel_toolbar_label'; + labelText.textContent = 'Custom Slot'; + label.appendChild(labelText); + + // 別モデルから値をインポート (Tools メニューと同じ動線・ダイアログ) + const importBtn = document.createElement('div'); + importBtn.className = 'tool head_right'; + importBtn.setAttribute(INJECTED_ATTR, 'import-btn'); + const importTip = document.createElement('div'); + importTip.className = 'tooltip'; + importTip.textContent = '別モデルから display 値を一括 import'; + importBtn.appendChild(importTip); + const importIcon = document.createElement('i'); + importIcon.className = 'material-icons'; + importIcon.textContent = 'file_download'; + importBtn.appendChild(importIcon); + importBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + importDisplayFromFile(); + }); + label.appendChild(importBtn); + + const bar = document.createElement('div'); + bar.id = CUSTOM_BAR_ID; + bar.className = 'bar tabs_small icon_bar'; + bar.setAttribute(INJECTED_ATTR, 'bar'); + + TARGETS.forEach((target) => { + const id = safeId(target.key); + + const input = document.createElement('input'); + input.type = 'radio'; + input.name = 'display'; + input.id = id; + input.className = 'hidden'; + input.setAttribute(INJECTED_ATTR, target.key); + + const tool = document.createElement('label'); + tool.className = 'tool'; + tool.htmlFor = id; + tool.setAttribute(INJECTED_ATTR, target.key); + + const tip = document.createElement('div'); + tip.className = 'tooltip'; + tip.textContent = target.tooltip; + tool.appendChild(tip); + + const icon = document.createElement('i'); + icon.className = 'material-icons'; + icon.textContent = target.icon; + tool.appendChild(icon); + + tool.addEventListener('click', () => loadCustomSlot(target)); + + bar.appendChild(input); + bar.appendChild(tool); + }); + + return { label, bar }; + } + + function injectCustomBar() { + if (document.getElementById(CUSTOM_BAR_ID)) return; + const refBar = document.getElementById(REF_BAR_ID); + if (!refBar || !refBar.parentNode) return; + const { label, bar } = buildCustomBar(); + // Reference Model の直後に label → bar の順で挿入 + refBar.parentNode.insertBefore(label, refBar.nextSibling); + label.parentNode.insertBefore(bar, label.nextSibling); + // 現在の display_slot がカスタムキーなら radio を checked に + try { + if (typeof DisplayMode !== 'undefined' && DisplayMode.display_slot) { + const radio = document.getElementById(safeId(DisplayMode.display_slot)); + if (radio) radio.checked = true; + } + } catch (e) { } + } + + function removeInjected() { + document.querySelectorAll('[' + INJECTED_ATTR + ']').forEach((el) => el.remove()); + } + + function setupObserver() { + if (observer) return; + observer = new MutationObserver(() => { + if (!document.getElementById(REF_BAR_ID)) return; + if (!document.getElementById(CUSTOM_BAR_ID)) injectCustomBar(); + }); + observer.observe(document.body, { childList: true, subtree: true }); + } + + function teardownObserver() { + if (observer) { + observer.disconnect(); + observer = null; + } + } + + // ─── Edit dialog (Tools menu, numeric direct entry) ──────────────── + + function getSlotValues(key) { + const p = getProject(); + const def = { rotation: [0, 0, 0], translation: [0, 0, 0], scale: [1, 1, 1] }; + if (!p || !p.display_settings) return def; + const s = p.display_settings[key]; + if (!s) return def; + return { + rotation: (s.rotation || [0, 0, 0]).slice(), + translation: (s.translation || [0, 0, 0]).slice(), + scale: (s.scale || [1, 1, 1]).slice(), + }; + } + + function setSlotValues(key, v) { + const p = getProject(); + if (!p) { + Blockbench.showQuickMessage('モデルを開いてください', 1500); + return false; + } + if (!p.display_settings) p.display_settings = {}; + if (!p.display_settings[key]) p.display_settings[key] = new DisplaySlot(key); + const slot = p.display_settings[key]; + slot.rotation = v.rotation.slice(); + slot.translation = v.translation.slice(); + slot.scale = v.scale.slice(); + if (p.saved !== undefined) p.saved = false; + try { DisplayMode.updateDisplayBase(); } catch (e) { } + return true; + } + + function openEditDialog(target) { + const cur = getSlotValues(target.key); + const dlg = new Dialog({ + id: 'edit_' + safeId(target.key), + title: 'Edit: ' + target.tooltip, + width: 480, + form: { + _info: { type: 'info', text: target.tooltip + '\n\n' + target.key }, + _div1: '_', + rotX: { label: 'Rotation X', type: 'number', value: cur.rotation[0], step: 1 }, + rotY: { label: 'Rotation Y', type: 'number', value: cur.rotation[1], step: 1 }, + rotZ: { label: 'Rotation Z', type: 'number', value: cur.rotation[2], step: 1 }, + _div2: '_', + transX: { label: 'Translation X', type: 'number', value: cur.translation[0], step: 0.1 }, + transY: { label: 'Translation Y', type: 'number', value: cur.translation[1], step: 0.1 }, + transZ: { label: 'Translation Z', type: 'number', value: cur.translation[2], step: 0.1 }, + _div3: '_', + scaleX: { label: 'Scale X', type: 'number', value: cur.scale[0], step: 0.05 }, + scaleY: { label: 'Scale Y', type: 'number', value: cur.scale[1], step: 0.05 }, + scaleZ: { label: 'Scale Z', type: 'number', value: cur.scale[2], step: 0.05 }, + }, + buttons: ['dialog.confirm', 'dialog.cancel'], + onConfirm(result) { + setSlotValues(target.key, { + rotation: [result.rotX, result.rotY, result.rotZ], + translation: [result.transX, result.transY, result.transZ], + scale: [result.scaleX, result.scaleY, result.scaleZ], + }); + Blockbench.showQuickMessage(target.tooltip.split(' — ')[0] + ' を更新しました (Ctrl+S で保存)', 2000); + dlg.hide(); + }, + }); + dlg.show(); + } + + // ─── Import display values from another model file ──────────────── + // 別のモデルファイル (.json / .bbmodel) を開き、その display 値を + // 現在のプロジェクトのカスタムスロットに取り込む。 + // ダイアログで「ソーススロット」「ターゲットスロット」「Rotation/ + // Translation/Scale のどれを取り込むか」を選択できるので、部分置換可。 + + function formatSlotPreview(slot) { + if (!slot) return '(empty)'; + const fmt = (a, def) => { + const arr = Array.isArray(a) ? a : def; + return '[' + arr.map((n) => { + const num = Number(n); + return isFinite(num) ? Number(num.toFixed(3)) : 0; + }).join(', ') + ']'; + }; + return [ + 'rotation: ' + fmt(slot.rotation, [0, 0, 0]), + 'translation: ' + fmt(slot.translation, [0, 0, 0]), + 'scale: ' + fmt(slot.scale, [1, 1, 1]), + ].join('\n'); + } + + // ソースに存在する全 display キー (vanilla = gui/head/ground/firstperson_*/ + // thirdperson_*/fixed/on_shelf/embedded + custom = TARGETS の key) を + // チェック候補とし、チェック済み slot × チェック済み 9 軸を一括置換。 + // 編集中スロットが置換対象だった場合は Vue を再バインドして slider にも反映。 + function applyBulkImport(sourceDisplay, result) { + const p = getProject(); + if (!p) { + Blockbench.showQuickMessage('モデルを開いてください', 1500); + return; + } + if (!p.display_settings) p.display_settings = {}; + + // チャンネル × 軸の chosen フラグ + const channelAxisFlags = { + rotation: [!!result.useRotX, !!result.useRotY, !!result.useRotZ], + translation: [!!result.useTransX, !!result.useTransY, !!result.useTransZ], + scale: [!!result.useScaleX, !!result.useScaleY, !!result.useScaleZ], + }; + const anyAxisChecked = Object.values(channelAxisFlags) + .some((arr) => arr.some(Boolean)); + if (!anyAxisChecked) { + Blockbench.showQuickMessage('置換する軸が1つも選択されていません', 1800); + return; + } + const channelDefaults = { + rotation: [0, 0, 0], translation: [0, 0, 0], scale: [1, 1, 1], + }; + const axisLetters = ['X', 'Y', 'Z']; + + const detail = []; + const replacedKeys = []; + + Object.keys(sourceDisplay).forEach((key) => { + const flag = result['slot_' + safeId(key)]; + if (!flag) return; + const src = sourceDisplay[key]; + if (!src || typeof src !== 'object') return; + if (!p.display_settings[key]) { + p.display_settings[key] = new DisplaySlot(key); + } + const dst = p.display_settings[key]; + const channelSummary = []; + + Object.keys(channelAxisFlags).forEach((channel) => { + const axisFlags = channelAxisFlags[channel]; + if (!axisFlags.some(Boolean)) return; // この channel 全 OFF + if (!Array.isArray(src[channel])) return; // source に値なし + if (!Array.isArray(dst[channel])) { + dst[channel] = channelDefaults[channel].slice(); + } + const next = dst[channel].slice(); + const writtenAxes = []; + for (let i = 0; i < 3; i++) { + if (axisFlags[i] && typeof src[channel][i] === 'number') { + next[i] = src[channel][i]; + writtenAxes.push(axisLetters[i]); + } + } + if (writtenAxes.length > 0) { + dst[channel] = next; // 配列差替で Vue 反応 + channelSummary.push(channel + '.' + writtenAxes.join('')); + } + }); + + if (channelSummary.length > 0) { + detail.push(key + ' ← ' + channelSummary.join(' + ')); + replacedKeys.push(key); + } + }); + + if (replacedKeys.length === 0) { + Blockbench.showQuickMessage('置換対象がありませんでした (ソースに該当キーが無いか、何もチェックされていない)', 2500); + return; + } + + if (p.saved !== undefined) p.saved = false; + + // 編集中スロットが置換対象だった場合、Vue 側の slot 参照も同じオブジェクト + // に張り直して slider 値を即時更新する。 + try { + if (typeof DisplayMode !== 'undefined' && DisplayMode.display_slot + && replacedKeys.includes(DisplayMode.display_slot)) { + const active = p.display_settings[DisplayMode.display_slot]; + DisplayMode.slot = active; + if (DisplayMode.vue && DisplayMode.vue._data) { + DisplayMode.vue._data.slot = active; + } + if (DisplayMode.vue && DisplayMode.vue.$forceUpdate) DisplayMode.vue.$forceUpdate(); + } + } catch (e) { } + try { DisplayMode.updateDisplayBase(); } catch (e) { } + + Blockbench.showQuickMessage( + replacedKeys.length + ' slot 置換完了: ' + detail.join(' / '), + 3500); + } + + function openImportDialog(sourceDisplay, sourceFileName) { + const sourceKeys = Object.keys(sourceDisplay); + + // vanilla を先 (Blockbench の Slot 行と同じ順序)、その後 custom (TARGETS)、 + // その他 (未知の名前空間など) は末尾。ソース内のキーすべてが対象候補。 + const VANILLA_ORDER = [ + 'thirdperson_righthand', 'thirdperson_lefthand', + 'firstperson_righthand', 'firstperson_lefthand', + 'head', 'gui', 'ground', 'fixed', 'on_shelf', 'embedded', + ]; + const customKeys = TARGETS.map((t) => t.key); + const orderedKeys = []; + VANILLA_ORDER.forEach((k) => { if (sourceKeys.includes(k)) orderedKeys.push(k); }); + customKeys.forEach((k) => { if (sourceKeys.includes(k) && !orderedKeys.includes(k)) orderedKeys.push(k); }); + sourceKeys.forEach((k) => { if (!orderedKeys.includes(k)) orderedKeys.push(k); }); + + // 各キーのチェックボックスを生成。デフォルト ON で「全部置換」をワンクリック化。 + const slotFields = {}; + const slotInfoLines = []; + orderedKeys.forEach((key) => { + const id = 'slot_' + safeId(key); + const isCustom = customKeys.includes(key); + const isVanilla = VANILLA_ORDER.includes(key); + const tag = isVanilla ? '(vanilla)' : (isCustom ? '(custom)' : '(other)'); + slotFields[id] = { + label: key + ' ' + tag, + type: 'checkbox', + value: true, + }; + slotInfoLines.push('✓ ' + key + ' ' + tag + + ' — ' + formatSlotPreview(sourceDisplay[key]).split('\n').map((s) => s.trim()).join(' / ')); + }); + + // 9 軸チェックボックス (Rotation/Translation/Scale × X/Y/Z) で部分置換可 + const formDef = { + _src_info: { + type: 'info', + text: 'Source: ' + sourceFileName + '\n\n' + + 'カスタムキー対応状況:\n ' + slotInfoLines.join('\n '), + }, + _div0: '_', + _rot_label: { type: 'info', text: '【Rotation】 取り込む軸:' }, + useRotX: { label: 'Rotation X', type: 'checkbox', value: true }, + useRotY: { label: 'Rotation Y', type: 'checkbox', value: true }, + useRotZ: { label: 'Rotation Z', type: 'checkbox', value: true }, + _trans_label: { type: 'info', text: '【Translation】 取り込む軸:' }, + useTransX: { label: 'Translation X', type: 'checkbox', value: true }, + useTransY: { label: 'Translation Y', type: 'checkbox', value: true }, + useTransZ: { label: 'Translation Z', type: 'checkbox', value: true }, + _scale_label: { type: 'info', text: '【Scale】 取り込む軸:' }, + useScaleX: { label: 'Scale X', type: 'checkbox', value: true }, + useScaleY: { label: 'Scale Y', type: 'checkbox', value: true }, + useScaleZ: { label: 'Scale Z', type: 'checkbox', value: true }, + _div1: '_', + _slots_label: { + type: 'info', + text: '置換するターゲットスロット (ソースに存在する vanilla + custom 全部):', + }, + }; + Object.assign(formDef, slotFields); + + const dlg = new Dialog({ + id: 'sb_import_display_dialog', + title: 'Bulk import display values from ' + sourceFileName, + width: 600, + form: formDef, + buttons: ['dialog.confirm', 'dialog.cancel'], + onConfirm(result) { + applyBulkImport(sourceDisplay, result); + dlg.hide(); + }, + }); + dlg.show(); + } + + function importDisplayFromFile() { + if (typeof Blockbench === 'undefined' || !Blockbench.import) { + Blockbench.showQuickMessage('Blockbench.import が利用できません', 1500); + return; + } + Blockbench.import({ + resource_id: 'sb_import_display', + extensions: ['json', 'bbmodel'], + type: 'JSON / Blockbench model', + readtype: 'text', + }, function (files) { + if (!files || !files[0] || !files[0].content) return; + const file = files[0]; + let parsed; + try { parsed = JSON.parse(file.content); } + catch (e) { + Blockbench.showMessageBox({ + title: 'JSON parse error', + message: 'Failed to parse file:\n' + (e && e.message ? e.message : e), + }); + return; + } + const displayData = parsed.display || parsed.display_settings; + if (!displayData || typeof displayData !== 'object' + || Object.keys(displayData).length === 0) { + Blockbench.showMessageBox({ + title: 'No display data', + message: 'This file has no `display` section.', + }); + return; + } + openImportDialog(displayData, file.name || 'unknown'); + }); + } + + // ─── Center model at origin ─────────────────────────────────────── + // 全要素の bounding box 中心を (0,0,0) に揃える。Display モードの + // 回転はワールド原点を軸に回るので、モデルが原点に居る = 回転が + // モデル中心軸で行われる (= 実質的にピボットが中心になる)。 + // + // 対応する要素タイプ: + // - Cube : from / to / origin + // - Mesh : vertices (record) / origin + // - Locator/Null : position + // - Group : origin (グループ全体のピボット) + + function computeModelBBox() { + if (!Array.isArray(Project.elements) || Project.elements.length === 0) return null; + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + let any = false; + const expand = (x, y, z) => { + if (!isFinite(x) || !isFinite(y) || !isFinite(z)) return; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; + any = true; + }; + Project.elements.forEach((el) => { + if (Array.isArray(el.from) && Array.isArray(el.to)) { + expand(el.from[0], el.from[1], el.from[2]); + expand(el.to[0], el.to[1], el.to[2]); + } + if (el.vertices && typeof el.vertices === 'object') { + Object.values(el.vertices).forEach((v) => { + if (Array.isArray(v) && v.length >= 3) expand(v[0], v[1], v[2]); + }); + } + if (Array.isArray(el.position) + && !Array.isArray(el.from) && !el.vertices) { + expand(el.position[0], el.position[1], el.position[2]); + } + }); + if (!any) return null; + return { + min: [minX, minY, minZ], + max: [maxX, maxY, maxZ], + center: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2], + size: [maxX - minX, maxY - minY, maxZ - minZ], + }; + } + + function applyCenterModel(cx, cy, cz) { + const elements = Project.elements || []; + const groups = (typeof Group !== 'undefined' && Group.all) ? Group.all : []; + + // Undo: outliner: true でグループ origin の差分まで含めて snapshot を取る。 + // (v4.4.0 では group: groups 渡しで Undo が効かなかったので修正) + try { + if (typeof Undo !== 'undefined' && Undo.initEdit) { + Undo.initEdit({ elements: elements, outliner: true }); + } + } catch (e) { + console.warn('[' + PLUGIN_ID + '] Undo.initEdit failed', e); + } + + const sub = (a) => [a[0] - cx, a[1] - cy, a[2] - cz]; + + elements.forEach((el) => { + if (Array.isArray(el.from)) el.from = sub(el.from); + if (Array.isArray(el.to)) el.to = sub(el.to); + if (Array.isArray(el.origin)) el.origin = sub(el.origin); + if (Array.isArray(el.position)) el.position = sub(el.position); + if (el.vertices && typeof el.vertices === 'object') { + Object.keys(el.vertices).forEach((k) => { + const v = el.vertices[k]; + if (Array.isArray(v) && v.length >= 3) { + el.vertices[k] = [v[0] - cx, v[1] - cy, v[2] - cz]; + } + }); + } + try { if (el.preview_controller && el.preview_controller.updateGeometry) el.preview_controller.updateGeometry(el); } catch (e) { } + }); + + groups.forEach((g) => { + if (Array.isArray(g.origin)) g.origin = sub(g.origin); + }); + + try { Canvas.updateAll(); } catch (e) { } + try { if (typeof Canvas !== 'undefined' && Canvas.updateAllPositions) Canvas.updateAllPositions(); } catch (e) { } + + try { + if (typeof Undo !== 'undefined' && Undo.finishEdit) { + Undo.finishEdit('Center model at origin'); + } + } catch (e) { + console.warn('[' + PLUGIN_ID + '] Undo.finishEdit failed', e); + } + + if (Project && Project.saved !== undefined) Project.saved = false; + } + + // ─── BBox helpers (shared by Center Model + Center Pivot) ──────── + + function computeBBoxOf(elements) { + if (!Array.isArray(elements) || elements.length === 0) return null; + let minX = Infinity, minY = Infinity, minZ = Infinity; + let maxX = -Infinity, maxY = -Infinity, maxZ = -Infinity; + let any = false; + const expand = (x, y, z) => { + if (!isFinite(x) || !isFinite(y) || !isFinite(z)) return; + if (x < minX) minX = x; if (x > maxX) maxX = x; + if (y < minY) minY = y; if (y > maxY) maxY = y; + if (z < minZ) minZ = z; if (z > maxZ) maxZ = z; + any = true; + }; + elements.forEach((el) => { + if (Array.isArray(el.from) && Array.isArray(el.to)) { + expand(el.from[0], el.from[1], el.from[2]); + expand(el.to[0], el.to[1], el.to[2]); + } else if (el.vertices && typeof el.vertices === 'object') { + Object.values(el.vertices).forEach((v) => { + if (Array.isArray(v) && v.length >= 3) expand(v[0], v[1], v[2]); + }); + } else if (Array.isArray(el.position)) { + expand(el.position[0], el.position[1], el.position[2]); + } + }); + if (!any) return null; + return { + center: [(minX + maxX) / 2, (minY + maxY) / 2, (minZ + maxZ) / 2], + size: [maxX - minX, maxY - minY, maxZ - minZ], + }; + } + + // ─── Center pivot of groups (non-destructive) ───────────────────── + // 各グループの origin (ピボット) を、その子要素の bbox 中心に設定する。 + // モデル自体のジオメトリは移動しない。Blockbench 標準の "Center Pivot" + // と同じだが、Tools / outliner ctx menu からまとめて呼べるようにする。 + + function applyCenterPivots(targetGroups) { + if (!Array.isArray(targetGroups) || targetGroups.length === 0) return 0; + try { Undo.initEdit({ outliner: true, group: targetGroups }); } catch (e) { } + let count = 0; + targetGroups.forEach((g) => { + if (!g || !Array.isArray(g.children)) return; + const bbox = computeBBoxOf(g.children); + if (!bbox) return; + g.origin = bbox.center.slice(); + count++; + }); + try { Canvas.updateAll(); } catch (e) { } + try { Undo.finishEdit('Center pivot of groups'); } catch (e) { } + if (Project && Project.saved !== undefined && count > 0) Project.saved = false; + return count; + } + + function centerPivotOfGroups() { + const p = getProject(); + if (!p) { + Blockbench.showQuickMessage('モデルを開いてください', 1500); + return; + } + if (typeof Group === 'undefined') { + Blockbench.showQuickMessage('Group API が利用できません', 1500); + return; + } + // 選択中グループあればそれ、無ければ全グループ + let targets = []; + if (Group.selected) { + targets = [Group.selected]; + } else if (Array.isArray(Group.all)) { + targets = Group.all.slice(); + } + if (targets.length === 0) { + Blockbench.showQuickMessage('対象グループがありません', 1500); + return; + } + const count = applyCenterPivots(targets); + Blockbench.showQuickMessage( + count + ' グループのピボットを子 bbox 中心に設定しました', 2200); + } + + function centerModelAtOrigin() { + const p = getProject(); + if (!p) { + Blockbench.showQuickMessage('モデルを開いてください', 1500); + return; + } + const bbox = computeModelBBox(); + if (!bbox) { + Blockbench.showQuickMessage('要素が見つかりません', 1500); + return; + } + const [cx, cy, cz] = bbox.center; + if (Math.abs(cx) < 0.001 && Math.abs(cy) < 0.001 && Math.abs(cz) < 0.001) { + Blockbench.showQuickMessage('既に原点中心です', 1500); + return; + } + const fmt = (n) => (n >= 0 ? '+' : '') + n.toFixed(3); + Blockbench.showMessageBox({ + title: 'Center Model at Origin', + message: 'BBox 中心: [' + cx.toFixed(3) + ', ' + cy.toFixed(3) + ', ' + cz.toFixed(3) + ']\n' + + 'BBox サイズ: [' + bbox.size[0].toFixed(3) + ', ' + bbox.size[1].toFixed(3) + ', ' + bbox.size[2].toFixed(3) + ']\n\n' + + '全要素 (cube / mesh / locator) とグループ origin を以下だけ平行移動します:\n' + + ' [' + fmt(-cx) + ', ' + fmt(-cy) + ', ' + fmt(-cz) + ']\n\n' + + 'これでモデル中心が (0,0,0) に揃い、Display モードの Rotation が\n' + + 'モデル中心軸を中心に回るようになります。\n\n' + + '(ジオメトリを直接書き換えます。Ctrl+Z で取り消し可能)', + buttons: ['Apply', 'Cancel'], + }, function (btn) { + if (btn !== 0) return; + applyCenterModel(cx, cy, cz); + Blockbench.showQuickMessage( + 'モデル中心化完了: [' + fmt(-cx) + ', ' + fmt(-cy) + ', ' + fmt(-cz) + ']', 2500); + }); + } + + // ─── DisplayMode.slots registration ──────────────────────────────── + // 保存/読込時に DisplayMode.slots に含まれる key だけが処理されるので + // ここで push しておかないと開き直したとき値が消える。 + + function registerSlotsInDisplayMode() { + if (typeof DisplayMode === 'undefined' || !Array.isArray(DisplayMode.slots)) return; + TARGETS.forEach((t) => { + if (!DisplayMode.slots.includes(t.key)) DisplayMode.slots.push(t.key); + }); + } + + function unregisterSlotsFromDisplayMode() { + if (typeof DisplayMode === 'undefined' || !Array.isArray(DisplayMode.slots)) return; + TARGETS.forEach((t) => { + const i = DisplayMode.slots.indexOf(t.key); + if (i >= 0) DisplayMode.slots.splice(i, 1); + }); + } + + // ─── plugin registration ─────────────────────────────────────────── + + Plugin.register(PLUGIN_ID, { + title: 'SB Worn Display Editor', + author: 'hrmcngs', + icon: 'backpack', + description: 'Adds a Custom Slot row to the Display panel so you can edit custom item display keys (Sophisticated Backpacks worn, MAW saya back/belt, Backpack-Arsenal chestplate / placed) visually in the 3D viewport, using the same sliders as the vanilla slots.', + tags: ['Minecraft: Java Edition', 'Modeling'], + version: '4.13.0', + min_version: '4.8.0', + variant: 'both', + website: 'https://github.com/hrmcngs/sb-worn-display-blockbench', + repository: 'https://github.com/hrmcngs/sb-worn-display-blockbench', + bug_tracker: 'https://github.com/hrmcngs/sb-worn-display-blockbench/issues', + creation_date: '2026-05-31', + + onload() { + registerSlotsInDisplayMode(); + + // Tools メニューに Edit ダイアログ (fallback / 数値入力用) + TARGETS.forEach((target, idx) => { + const aEdit = new Action('custom_disp_edit_' + safeId(target.key), { + name: '[' + (idx + 1) + '] Edit (numbers): ' + target.tooltip.split(' — ')[0], + description: 'ダイアログで ' + target.key + ' を数値編集', + icon: 'tune', + category: 'edit', + click() { openEditDialog(target); }, + }); + try { MenuBar.addAction(aEdit, 'tools'); } catch (e) { } + actions.push(aEdit); + }); + + // Tools メニューに Import アクション (別モデルから display 値を取り込み) + const aImport = new Action('custom_disp_import', { + name: 'Bulk import display values from another model…', + description: '別の .json / .bbmodel ファイルを開き、ソース内の ' + + '全 display スロット (vanilla: head/gui/ground/firstperson_*/' + + 'thirdperson_*/fixed/on_shelf/embedded + custom: SB Worn / ' + + 'MAW Back / MAW Belt 等) を一括置換する。スロット × 9 軸 ' + + '(Rotation/Translation/Scale 各 X/Y/Z) を個別にチェック可能。', + icon: 'file_download', + category: 'edit', + click() { importDisplayFromFile(); }, + }); + try { MenuBar.addAction(aImport, 'tools'); } catch (e) { } + actions.push(aImport); + + // Center Model アクション (ジオメトリを動かす破壊的操作・Undo 可) + const aCenter = new Action('custom_disp_center_model', { + name: 'Center Model at Origin', + description: 'モデル全要素 (cube / mesh / locator) の bounding box ' + + '中心を (0,0,0) に揃え、Display モードの Rotation がモデル ' + + '中心を軸に回るようにする。Ctrl+Z で取り消し可能。', + icon: 'center_focus_strong', + category: 'edit', + click() { centerModelAtOrigin(); }, + }); + try { MenuBar.addAction(aCenter, 'tools'); } catch (e) { } + actions.push(aCenter); + + // Center Pivot of Groups アクション (各グループの origin を子 bbox 中心に・非破壊) + const aCenterPivot = new Action('custom_disp_center_pivots', { + name: 'Center Pivot of Groups', + description: '選択中のグループ (無ければ全グループ) の origin ' + + 'を子要素の bbox 中心に設定。モデルジオメトリは動かさず、' + + '回転ピボットだけモデル中心へ移す。Ctrl+Z で取り消し可能。', + icon: 'gps_fixed', + category: 'edit', + click() { centerPivotOfGroups(); }, + }); + try { MenuBar.addAction(aCenterPivot, 'tools'); } catch (e) { } + actions.push(aCenterPivot); + + // Outliner コンテキストメニュー (Cube / Group / Mesh の右クリック) にも追加。 + // Center View は Blockbench 本体の "focus_on_selection" (= 'センタービュー') + // をそのまま参照するので自前実装はしない。 + const ctxMenuTargets = []; + try { if (typeof Cube !== 'undefined' && Cube.prototype && Cube.prototype.menu) ctxMenuTargets.push(Cube.prototype.menu); } catch (e) { } + try { if (typeof Group !== 'undefined' && Group.prototype && Group.prototype.menu) ctxMenuTargets.push(Group.prototype.menu); } catch (e) { } + try { if (typeof Mesh !== 'undefined' && Mesh.prototype && Mesh.prototype.menu) ctxMenuTargets.push(Mesh.prototype.menu); } catch (e) { } + + const builtInCenterView = (typeof BarItems !== 'undefined') ? BarItems.focus_on_selection : null; + + ctxMenuTargets.forEach((menu) => { + if (!menu || typeof menu.addAction !== 'function') return; + if (builtInCenterView) { + try { menu.addAction(builtInCenterView); } catch (e) { } + } + try { menu.addAction(aCenter); } catch (e) { } + try { menu.addAction(aCenterPivot); } catch (e) { } + }); + + setupObserver(); + injectCustomBar(); + + try { + modeListener = () => setTimeout(() => { + injectCustomBar(); + rebindActiveCustomSlot(); + }, 50); + Blockbench.on('select_mode', modeListener); + Blockbench.on('select_project', modeListener); + } catch (e) { } + + console.log('[' + PLUGIN_ID + '] v4.9.0 loaded — ' + + '(bulk import + Center Model + Center Pivot + built-in Center View) — ' + + TARGETS.length + ' custom display slots available'); + }, + + onunload() { + stopSync(); + teardownObserver(); + removeInjected(); + unregisterSlotsFromDisplayMode(); + try { + if (modeListener) { + Blockbench.removeListener('select_mode', modeListener); + Blockbench.removeListener('select_project', modeListener); + } + } catch (e) { } + modeListener = null; + actions.forEach((a) => { try { a.delete(); } catch (e) { } }); + actions.length = 0; + }, + }); +})();