diff --git a/AnimationDirector.js b/AnimationDirector.js
index e02cddc..3b64c64 100644
--- a/AnimationDirector.js
+++ b/AnimationDirector.js
@@ -12,6 +12,9 @@
const blockGuideMaterials = new Map();
let blockRequirementAction, blockRequirementsProjectProperty, blockGuidePreviewListener;
let blockRequirementUndoSaveListener, blockRequirementUndoLoadListener;
+ let blockRequirementPanel, blockRequirementPanelListener, blockRequirementPanelCss;
+ let blockRequirementPendingTimer;
+ let blockGuideProjectListener;
const SYSTEM_TAG_OPTIONS = {
'': 'None (normal animation)',
manualpeak: 'Manual peak',
@@ -738,6 +741,56 @@
cube.mesh.material = material;
}
+ function hideInternalBlockGuideFaces(cubes) {
+ const faces = [['west', 0, 0], ['east', 0, 1], ['down', 1, 0], ['up', 1, 1], ['north', 2, 0], ['south', 2, 1]];
+ const planes = new Map();
+ const descriptors = [];
+ const epsilon = 0.000001;
+ for (const cube of cubes) {
+ const kind = cube.name.includes('_clearance_') ? 'clearance' : 'required';
+ for (const [name, axis, positive] of faces) {
+ if (!cube.faces?.[name]) continue;
+ const axes = [0, 1, 2].filter((value) => value !== axis);
+ const rect = [cube.from[axes[0]], cube.from[axes[1]], cube.to[axes[0]], cube.to[axes[1]]];
+ const key = `${kind}:${axis}:${(positive ? cube.to[axis] : cube.from[axis]).toFixed(6)}`;
+ const descriptor = { cube, name, positive, rect, key };
+ if (!planes.has(key)) planes.set(key, [[], []]);
+ planes.get(key)[positive].push(descriptor);
+ descriptors.push(descriptor);
+ }
+ }
+ for (const face of descriptors) {
+ let uncovered = [face.rect];
+ // Subtract neighboring rectangles; several smaller faces can cover one large face.
+ for (const neighbor of planes.get(face.key)[1 - face.positive]) {
+ if (neighbor.cube === face.cube) continue;
+ const next = [];
+ for (const rect of uncovered) {
+ const left = Math.max(rect[0], neighbor.rect[0]), bottom = Math.max(rect[1], neighbor.rect[1]);
+ const right = Math.min(rect[2], neighbor.rect[2]), top = Math.min(rect[3], neighbor.rect[3]);
+ if (right - left <= epsilon || top - bottom <= epsilon) { next.push(rect); continue; }
+ if (left - rect[0] > epsilon) next.push([rect[0], rect[1], left, rect[3]]);
+ if (rect[2] - right > epsilon) next.push([right, rect[1], rect[2], rect[3]]);
+ if (bottom - rect[1] > epsilon) next.push([left, rect[1], right, bottom]);
+ if (rect[3] - top > epsilon) next.push([left, top, right, rect[3]]);
+ }
+ uncovered = next;
+ if (!uncovered.length) break;
+ }
+ // Null faces are omitted from Blockbench's mesh index, not just made transparent.
+ face.cube.faces[face.name].texture = uncovered.length ? false : null;
+ }
+ }
+
+ function refreshBlockGuideFaces() {
+ const roots = Group.all.filter((group) => group.name.startsWith(BLOCK_GUIDE_PREFIX)
+ && !isBlockRequirementGuide(group.parent));
+ for (const root of roots) {
+ hideInternalBlockGuideFaces(Cube.all.filter((cube) => cube.getAllAncestors().includes(root)));
+ }
+ if (roots.length) Canvas.updateAllFaces();
+ }
+
function rebuildBlockRequirementGuides(animationId, entry) {
const boxes = entry ? buildBlockRequirementGuideBoxes(entry) : [];
const groupName = `${BLOCK_GUIDE_PREFIX}${animationId}`;
@@ -751,21 +804,28 @@
const createdGroups = [];
Undo.initEdit({ outliner: true, elements: oldElements, groups: oldGroups,
block_requirement_edit: true });
- for (const group of oldGroups) group.remove();
if (entry) {
- const root = new Group({ name: groupName, export: false });
- root.init();
- root.addTo('root');
+ const root = oldGroups[0] || new Group({ name: groupName, export: false });
+ if (!oldGroups.length) { root.init(); root.addTo('root'); }
createdGroups.push(root);
boxes.forEach((box, index) => {
- const cube = new Cube({ name: `${BLOCK_GUIDE_PREFIX}${box.kind}_${box.label}_${index + 1}`,
- from: box.from, to: box.to, export: false, box_uv: false });
- cube.init();
- cube.addTo(root);
+ const data = { name: `${BLOCK_GUIDE_PREFIX}${box.kind}_${box.label}_${index + 1}`,
+ from: box.from, to: box.to, export: false, box_uv: false };
+ const cube = oldElements[index] || new Cube(data);
+ if (oldElements[index]) {
+ cube.name = data.name;
+ cube.from = box.from.slice();
+ cube.to = box.to.slice();
+ cube.export = false;
+ } else { cube.init(); cube.addTo(root); }
createdElements.push(cube);
});
+ for (const cube of oldElements.slice(boxes.length)) cube.remove();
+ } else {
+ for (const group of oldGroups) group.remove();
}
Project[BLOCK_REQUIREMENTS_PROPERTY] = JSON.stringify(updated);
+ hideInternalBlockGuideFaces(createdElements);
Canvas.updateAll();
for (const cube of createdElements) applyBlockRequirementGuideMaterial(cube);
Undo.finishEdit(entry ? 'Set block requirement' : 'Remove block requirement',
@@ -773,7 +833,7 @@
Project.saved = false;
}
- function showBlockRequirementEditor(animationId) {
+ function buildBlockRequirementForm(animationId) {
const entry = getAuthoredBlockRequirement(animationId);
const req = (entry ? entry.requirement : getSavedAnimdef(animationId).block_requirements) || {};
const wall = (result) => result?.type === 'wall';
@@ -822,40 +882,149 @@
surface_height: { label: 'Custom surface height', type: 'number', value: entry?.preview?.surfaceHeight ?? 1, min: 0.0625, max: 2, step: 0.0625,
condition: (result) => center(result) && result?.support === 'surface', description: 'For example a bed is 0.5625 blocks high; slab/half-height mode uses 0.5.' },
};
- new Dialog({ id: 'animationdirector_block_requirement', title: 'Block Requirement', width: 680, form,
- buttons: ['Cancel', 'Remove Requirement', 'Save & Build Guides'], cancelIndex: 0, confirmIndex: 2,
- onButton(index) {
- if (index === 1) {
- rebuildBlockRequirementGuides(animationId, null);
- this.hide();
- }
+ return form;
+ }
+
+ function cancelBlockRequirementUpdate() {
+ if (blockRequirementPendingTimer !== undefined) clearTimeout(blockRequirementPendingTimer);
+ blockRequirementPendingTimer = undefined;
+ }
+
+ function blockRequirementAnimationIds() {
+ let groups = [];
+ try { groups = collectConjoinedAnimationGroups().groups; } catch (_) { /* Allow authoring before clips exist. */ }
+ return Array.from(new Set([...groups.map((group) => group.id), ...Object.keys(getBlockRequirementEntries())])).sort();
+ }
+
+ function createBlockRequirementPanel() {
+ blockRequirementPanelCss = Blockbench.addCSS(`
+ .ad-block-panel { padding: 10px; overflow-y: auto; height: 100%; box-sizing: border-box; }
+ .ad-block-panel p { margin: 6px 0; font-size: 12px; color: var(--color-text); opacity: .8; }
+ .ad-block-panel .ad-block-target { display: flex; gap: 6px; margin-bottom: 8px; }
+ .ad-block-panel .ad-block-target input { width: 0; flex: 1; }
+ .ad-block-panel select { width: 100%; min-width: 0; }
+ .ad-block-panel details { margin: 10px 0; border: 1px solid var(--color-border); border-radius: 4px; padding: 8px; }
+ .ad-block-panel summary { font-weight: bold; cursor: pointer; color: var(--color-accent); }
+ .ad-block-panel .ad-block-field { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; }
+ .ad-block-panel .ad-block-field label { width: 100%; font-size: 12px; }
+ .ad-block-panel .ad-block-field input:not([type=checkbox]) { width: 100%; box-sizing: border-box; }
+ .ad-block-panel .ad-block-error { color: var(--color-error); opacity: 1; }
+ .ad-block-panel .ad-block-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+ `);
+ blockRequirementPanel = new Panel('animationdirector_block_requirements', {
+ name: 'Block Requirements', icon: 'view_in_ar', condition: () => !!Project,
+ growable: true, resizable: true, min_height: 240, expand_button: true,
+ default_position: { slot: 'right_bar', height: 520, float_size: [360, 620] },
+ component: {
+ data() { return { projectId: null, animationId: '', draftId: '', ids: [], form: {}, values: {}, error: '', status: '', dirty: false }; },
+ computed: {
+ sections() {
+ const sections = [{ title: 'Requirement', fields: [] }];
+ for (const [key, field] of Object.entries(this.form)) {
+ if (key === '_about') continue;
+ if (field.condition && !field.condition(this.values)) continue;
+ if (field.type === 'info' && field.text.startsWith('### ')) {
+ sections.push({ title: field.text.slice(4).split('\n')[0], fields: [], hint: field.text.split('\n').slice(1).join('\n') });
+ } else sections[sections.length - 1].fields.push({ key, ...field });
+ }
+ return sections.filter((section) => section.fields.length);
+ },
+ },
+ methods: {
+ load(id) {
+ cancelBlockRequirementUpdate();
+ this.projectId = Project?.uuid || null;
+ this.ids = Project ? blockRequirementAnimationIds() : [];
+ this.animationId = id || '';
+ this.draftId = this.animationId;
+ this.form = Project && id ? buildBlockRequirementForm(id) : {};
+ this.values = Object.fromEntries(Object.entries(this.form).filter(([, field]) => field.type !== 'info').map(([key, field]) => [key, field.value]));
+ this.error = ''; this.dirty = false;
+ this.status = id ? 'Changes update the guides and are saved in this project automatically.' : 'Select an animation or enter a new animation ID.';
+ },
+ openId() {
+ const id = String(this.draftId).trim();
+ if (!/^[a-z0-9._-]+$/.test(id)) { this.error = 'Enter a valid lowercase animation ID (without the stage suffix).'; return; }
+ this.flush(); this.load(id);
+ },
+ selectId(event) { this.flush(); this.load(event.target.value); },
+ input(key, field, event) {
+ this.$set(this.values, key, field.type === 'checkbox' ? event.target.checked : event.target.value);
+ this.dirty = true;
+ cancelBlockRequirementUpdate();
+ try { buildBlockRequirementGuideBoxes(readBlockRequirementForm(this.values)); this.error = ''; }
+ catch (e) { this.error = `${e.message} The last valid preview is unchanged.`; return; }
+ // Coalesce a typing/arrow-key burst without keeping a native Undo edit open.
+ blockRequirementPendingTimer = setTimeout(() => this.flush(), 180);
+ },
+ flush(force = false) {
+ cancelBlockRequirementUpdate();
+ if (!this.animationId || !Project || this.projectId !== Project.uuid || (!this.dirty && !force)) return;
+ try {
+ const entry = readBlockRequirementForm(this.values);
+ buildBlockRequirementGuideBoxes(entry);
+ const hasGuides = Group.all.some((group) => group.name === `${BLOCK_GUIDE_PREFIX}${this.animationId}`);
+ if (!hasGuides || JSON.stringify(entry) !== JSON.stringify(getAuthoredBlockRequirement(this.animationId))) {
+ rebuildBlockRequirementGuides(this.animationId, entry);
+ }
+ this.error = ''; this.dirty = false;
+ this.ids = blockRequirementAnimationIds();
+ this.status = 'Preview updated. Blue: structure / floor. Red: clearance. Undo is available.';
+ } catch (e) { this.error = `${e.message} The last valid preview is unchanged.`; }
+ },
+ remove() {
+ cancelBlockRequirementUpdate();
+ if (!this.animationId || !Project || this.projectId !== Project.uuid) return;
+ rebuildBlockRequirementGuides(this.animationId, null);
+ this.load(this.animationId);
+ this.status = 'Requirement removed. Edit a value or click Build guides to add it again.';
+ },
+ },
+ template: `
+
+
+
Live guides: 16 pixels = 1 block. Actors face -Z; wall clearance extends toward +Z. Guides are not exported.
+
{{ error }}
{{ status }}
+
+ {{ section.title }}
{{ section.hint }}
+
+
{{ field.text }}
+
+
+
+ {{ field.description }}
+
+
+
+
+
`,
},
- onConfirm(result) {
- try { rebuildBlockRequirementGuides(animationId, readBlockRequirementForm(result)); return true; }
- catch (e) { Blockbench.showMessageBox({ title: 'Block Requirement', message: e.message, buttons: ['OK'] }); return false; }
- },
- }).show();
+ });
+ blockRequirementPanelListener = () => {
+ cancelBlockRequirementUpdate();
+ const view = blockRequirementPanel?.vue;
+ if (view) view.load(Project && view.projectId === Project.uuid ? view.animationId : '');
+ };
+ Blockbench.on('select_project unselect_project undo redo', blockRequirementPanelListener);
}
function addBlockRequirement() {
if (!Project) return Blockbench.showQuickMessage('Open a project first.', 2500);
- let groups;
- try { groups = collectConjoinedAnimationGroups().groups; }
- catch (e) { return Blockbench.showMessageBox({ title: 'Block Requirement', message: e.message, buttons: ['OK'] }); }
- const options = Object.fromEntries(groups.map((group) => [group.id, group.id]));
+ if (!blockRequirementPanel) createBlockRequirementPanel();
+ const view = blockRequirementPanel.vue;
+ view.flush();
const selected = parseAfwStageAnimationName(getSelectedAnimation());
- const defaultId = selected?.id || groups[0]?.id || '';
- new Dialog({ id: 'animationdirector_block_requirement_target', title: 'Add / Edit Block Requirement', width: 480,
- form: { animation: groups.length
- ? { label: 'Animation', type: 'select', options, value: options[defaultId] ? defaultId : groups[0].id }
- : { label: 'Animation ID', type: 'text', value: '', placeholder: 'wolfmplayer', description: 'Use the ID of the future .p1 stage, without .p1.' } },
- onConfirm(result) {
- const id = String(result.animation || '').trim();
- if (!/^[a-z0-9._-]+$/.test(id)) { Blockbench.showQuickMessage('Enter a valid lowercase animation ID.', 2500); return false; }
- showBlockRequirementEditor(id);
- return true;
- },
- }).show();
+ const ids = blockRequirementAnimationIds();
+ view.load(selected?.id || (view.projectId === Project.uuid && view.animationId) || ids[0] || '');
+ if (blockRequirementPanel.slot === 'hidden') blockRequirementPanel.moveTo('right_bar');
+ blockRequirementPanel.folded = false;
+ blockRequirementPanel.getContainerPanel().selectTab(blockRequirementPanel);
+ blockRequirementPanel.update();
+ if (blockRequirementPanel.slot === 'float') blockRequirementPanel.moveToFront();
}
function stripBlockRequirementGuideBones(clip) {
@@ -2740,8 +2909,8 @@
form._block_requirement = {
type: 'info',
text: blockRequirement
- ? `#### Block requirement\nIncluded automatically:\n\n\`\`\`json\n${JSON.stringify(blockRequirement, null, 2)}\n\`\`\`\nEdit using Tools > AnimationDirector Anims > Add Block Requirement.`
- : '#### Block requirement\nNone. Use Tools > AnimationDirector Anims > Add Block Requirement to create one.',
+ ? `#### Block requirement\nIncluded automatically:\n\n\`\`\`json\n${JSON.stringify(blockRequirement, null, 2)}\n\`\`\`\nEdit using Tools > AnimationDirector Anims > Block Requirements.`
+ : '#### Block requirement\nNone. Use Tools > AnimationDirector Anims > Block Requirements to create one.',
};
form.base_speed = {
label: 'Base playback speed', type: 'text', value: saved.speed ?? '', placeholder: 'Default (1.0)',
@@ -4033,6 +4202,9 @@ You can import or export complete AFW animation IDs as one file containing all s
blockGuidePreviewListener = ({ element }) => applyBlockRequirementGuideMaterial(element);
Cube.preview_controller.on('setup update_faces', blockGuidePreviewListener);
for (const cube of Cube.all) applyBlockRequirementGuideMaterial(cube);
+ blockGuideProjectListener = refreshBlockGuideFaces;
+ Blockbench.on('select_project', blockGuideProjectListener);
+ refreshBlockGuideFaces();
blockRequirementUndoSaveListener = ({ save, aspects }) => {
if (aspects.block_requirement_edit) save[BLOCK_REQUIREMENTS_PROPERTY] = Project[BLOCK_REQUIREMENTS_PROPERTY] || '{}';
};
@@ -4043,8 +4215,8 @@ You can import or export complete AFW animation IDs as one file containing all s
Blockbench.on('load_undo_save', blockRequirementUndoLoadListener);
blockRequirementAction = new Action('animationdirector_add_block_requirement', {
- name: 'Add Block Requirement',
- description: 'Configure a NoN block requirement and create blue structure / red clearance guides.',
+ name: 'Block Requirements',
+ description: 'Open the live block requirement panel with blue structure / red clearance guides.',
icon: 'view_in_ar',
click: addBlockRequirement,
});
@@ -4188,6 +4360,18 @@ You can import or export complete AFW animation IDs as one file containing all s
exportConjoinedAction.delete();
createAnimationDefinitionAction.delete();
blockRequirementAction.delete();
+ Blockbench.removeListener('select_project', blockGuideProjectListener);
+ cancelBlockRequirementUpdate();
+ if (blockRequirementPanelListener) {
+ for (const event of ['select_project', 'unselect_project', 'undo', 'redo']) {
+ Blockbench.removeListener(event, blockRequirementPanelListener);
+ }
+ }
+ blockRequirementPanel?.delete();
+ blockRequirementPanelCss?.delete();
+ blockRequirementPanel = undefined;
+ blockRequirementPanelListener = undefined;
+ blockRequirementPanelCss = undefined;
Cube.preview_controller.removeListener('setup', blockGuidePreviewListener);
Cube.preview_controller.removeListener('update_faces', blockGuidePreviewListener);
Blockbench.removeListener('create_undo_save', blockRequirementUndoSaveListener);
diff --git a/test/block-requirements.test.js b/test/block-requirements.test.js
new file mode 100644
index 0000000..52addd9
--- /dev/null
+++ b/test/block-requirements.test.js
@@ -0,0 +1,344 @@
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const test = require('node:test');
+const vm = require('node:vm');
+
+function harness() {
+ const source = fs.readFileSync(path.join(__dirname, '..', 'AnimationDirector.js'), 'utf8').replace(
+ ' Plugin.register(PLUGIN_ID, {',
+ ' globalThis.blockTest = { readBlockRequirementForm, buildBlockRequirementGuideBoxes, stripBlockRequirementGuideBones, showAnimationDefinitionReviewDialog, rebuildBlockRequirementGuides, getAuthoredBlockRequirement, compileGeckoModelJson, createBlockRequirementPanel, hideInternalBlockGuideFaces, refreshBlockGuideFaces, getPanel: () => blockRequirementPanel };\n Plugin.register(PLUGIN_ID, {'
+ );
+ const project = { uuid: 'project-one', multiactor_actor_names: JSON.stringify({ 1: 'player', 2: 'wolf' }) };
+ const dialogs = [];
+ const exports = [];
+ const errors = [];
+ const edits = [];
+ const events = new Map();
+ const previewEvents = new Map();
+ const timers = new Map();
+ let sequence = 0;
+ class Group {
+ static all = [];
+ constructor(data) { Object.assign(this, data, { uuid: `group-${++sequence}`, children: [] }); }
+ init() { Group.all.push(this); return this; }
+ addTo(parent) { this.parent = parent; if (parent instanceof Group) parent.children.push(this); }
+ remove() {
+ for (const child of this.children.slice()) child.remove();
+ Group.all = Group.all.filter((group) => group !== this);
+ }
+ }
+ class Cube {
+ static all = [];
+ static preview_controller = {
+ on(names, listener) { for (const name of names.split(' ')) previewEvents.set(name, listener); },
+ };
+ constructor(data) {
+ Object.assign(this, data, { uuid: `cube-${++sequence}`, mesh: {},
+ faces: Object.fromEntries(['north', 'south', 'east', 'west', 'up', 'down'].map((name) => [name, { texture: false }])) });
+ }
+ init() { Cube.all.push(this); }
+ addTo(parent) { this.parent = parent; parent.children.push(this); }
+ getAllAncestors() { return [this.parent]; }
+ remove() { Cube.all = Cube.all.filter((cube) => cube !== this); }
+ }
+ const context = {
+ Project: project, Group, Cube, Plugin: { register(id, definition) { context.registration = definition; } },
+ Action: class { constructor(id, options) { Object.assign(this, options); } },
+ Panel: class {
+ constructor(id, options) {
+ this.options = options;
+ this.vue = Object.assign(options.component.data(), options.component.methods);
+ this.vue.$set = (object, key, value) => { object[key] = value; };
+ }
+ },
+ setTimeout(callback) { const id = ++sequence; timers.set(id, callback); return id; },
+ clearTimeout(id) { timers.delete(id); },
+ MenuBar: { menus: { tools: { addAction() {} } }, update() {} },
+ THREE: { MeshBasicMaterial: class { constructor(options) { Object.assign(this, options); } }, DoubleSide: 2 },
+ Canvas: {
+ updateAll() { for (const cube of Cube.all) previewEvents.get('update_faces')?.({ element: cube }); },
+ updateAllFaces() { this.updateAll(); },
+ },
+ Undo: {
+ initEdit(aspects) { edits.push({ before: project.animationdirector_block_requirements, aspects }); },
+ finishEdit(action, aspects) { Object.assign(edits.at(-1), { after: project.animationdirector_block_requirements, action, post: aspects }); },
+ },
+ Dialog: class { constructor(options) { dialogs.push(options); } show() {} },
+ Blockbench: { export(options, callback) { exports.push(JSON.parse(options.content)); callback(); },
+ on(names, listener) { for (const name of names.split(' ')) events.set(name, listener); },
+ addCSS() { return { delete() {} }; },
+ showQuickMessage() {}, showMessageBox(options) { errors.push(options.message); } },
+ console,
+ };
+ vm.runInNewContext(source, context);
+ return { ...context.blockTest, context, project, dialogs, exports, errors, edits, events, previewEvents, timers, Group, Cube };
+}
+
+function wallForm(overrides = {}) {
+ return { type: 'wall', blocks: '#minecraft:fences', height_min: 3, height_max: '', width_min: 2, width_max: '',
+ facing: '', clearance_width: 1, clearance_height: 2, clearance_depth: 1, wall_thickness: 1, ...overrides };
+}
+
+test('guide cubes omit internal faces, preserve colored boundaries and restore newly exposed faces', () => {
+ const h = harness();
+ const make = (kind, from, to) => new h.Cube({ name: `__ad_block_requirement_${kind}_test`, from, to });
+ const left = make('required', [0, 0, 0], [16, 16, 16]);
+ const right = make('required', [16, 0, 0], [32, 16, 16]);
+ const clearance = make('clearance', [32, 0, 0], [48, 16, 16]);
+ h.hideInternalBlockGuideFaces([left, right, clearance]);
+ assert.equal(left.faces.east.texture, null);
+ assert.equal(right.faces.west.texture, null);
+ assert.equal(left.faces.west.texture, false);
+ assert.equal(right.faces.east.texture, false, 'Blue/red boundaries remain visible');
+ assert.equal(clearance.faces.west.texture, false);
+ h.hideInternalBlockGuideFaces([left]);
+ assert.equal(left.faces.east.texture, false, 'Removing a neighbor exposes its former shared face');
+});
+
+test('several neighbors can cover one larger guide face without hiding partially exposed surfaces', () => {
+ const h = harness();
+ const make = (from, to) => new h.Cube({ name: '__ad_block_requirement_clearance_test', from, to });
+ const tall = make([0, 0, 0], [16, 32, 16]);
+ const lower = make([16, 0, 0], [32, 16, 16]);
+ const upper = make([16, 16, 0], [32, 32, 16]);
+ h.hideInternalBlockGuideFaces([tall, lower, upper]);
+ assert.equal(tall.faces.east.texture, null);
+ assert.equal(lower.faces.west.texture, null);
+ assert.equal(upper.faces.west.texture, null);
+ assert.equal(lower.faces.up.texture, null);
+ assert.equal(upper.faces.down.texture, null);
+ h.hideInternalBlockGuideFaces([tall, lower]);
+ assert.equal(tall.faces.east.texture, false, 'The upper exposed half must not disappear');
+ assert.equal(lower.faces.up.texture, false);
+});
+
+test('generated and reloaded guides remove their shared faces without affecting another animation', () => {
+ const h = harness();
+ h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm()));
+ const firstFaces = h.Cube.all.map((cube) => JSON.stringify(cube.faces));
+ assert.ok(h.Cube.all.some((cube) => Object.values(cube.faces).some((face) => face.texture === null)));
+ h.rebuildBlockRequirementGuides('second', h.readBlockRequirementForm(wallForm()));
+ assert.deepEqual(h.Cube.all.slice(0, firstFaces.length).map((cube) => JSON.stringify(cube.faces)), firstFaces);
+ for (const cube of h.Cube.all) for (const face of Object.values(cube.faces)) face.texture = false;
+ h.refreshBlockGuideFaces();
+ assert.deepEqual(h.Cube.all.slice(0, firstFaces.length).map((cube) => JSON.stringify(cube.faces)), firstFaces);
+});
+
+test('wall data matches NoN minimum/maximum/facing syntax and ignores inactive fields', () => {
+ const h = harness();
+ const entry = h.readBlockRequirementForm(wallForm({ width_max: '2', height_max: '3', both_axes: true,
+ include_obstructions: true, facing: 'front', surface_radius: 9, footprint_width: 9 }));
+ assert.deepEqual(JSON.parse(JSON.stringify(entry.requirement)), {
+ type: 'wall', blocks: '#minecraft:fences', height: { min: 3, max: 3 },
+ width: { min: 2, max: 2, both_axes: true, include_obstructions: true }, facing: 'front',
+ clearance: { width: 1, height: 2, depth: 1 },
+ });
+ assert.equal(h.readBlockRequirementForm(wallForm({ both_axes: true })).requirement.width.both_axes, undefined);
+ assert.throws(() => h.readBlockRequirementForm(wallForm({ width_max: 1 })), /Maximum width/);
+ assert.throws(() => h.readBlockRequirementForm(wallForm({ blocks: '', facing: 'front' })), /facing requirement/);
+ assert.throws(() => h.readBlockRequirementForm(wallForm({ blocks: 'fence' })), /Invalid block/);
+});
+
+test('wall guides center an even span and reproduce side padding, anchor distance and required floors', () => {
+ const h = harness();
+ const entry = h.readBlockRequirementForm(wallForm({ clearance_width: 2 }));
+ const boxes = h.buildBlockRequirementGuideBoxes(entry);
+ const walls = boxes.filter((box) => box.label === 'wall');
+ assert.equal(walls.length, 6);
+ assert.equal(Math.min(...walls.map((box) => box.from[0])), -16);
+ assert.equal(Math.max(...walls.map((box) => box.to[0])), 16);
+ assert.equal(walls[0].from[2], -24);
+ assert.equal(walls[0].to[2], -8);
+ const clearance = boxes.filter((box) => box.kind === 'clearance');
+ assert.equal(clearance.length, 8);
+ assert.equal(Math.min(...clearance.map((box) => box.from[0])), -32);
+ assert.equal(Math.max(...clearance.map((box) => box.to[0])), 32);
+ assert.equal(clearance[0].from[2], -8);
+ assert.equal(boxes.filter((box) => box.label === 'floor').length, 6);
+ const thin = h.buildBlockRequirementGuideBoxes(h.readBlockRequirementForm(wallForm({ wall_thickness: 0.25 })));
+ assert.equal(thin.find((box) => box.label === 'wall').from[2], -12);
+ assert.equal(thin.find((box) => box.kind === 'clearance').from[2], -2);
+});
+
+test('center footprint supports fractional heights and clearance margins without directional fields', () => {
+ const h = harness();
+ const entry = h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'surface',
+ blocks: '#minecraft:beds', placement: 'surface_footprint', footprint_width: 2, footprint_depth: 2,
+ footprint_height: 2, footprint_margin: 1, surface_height: 0.5625 });
+ assert.equal(entry.requirement.clearance, undefined);
+ assert.equal(entry.requirement.height, undefined);
+ const boxes = h.buildBlockRequirementGuideBoxes(entry);
+ assert.equal(boxes.filter((box) => box.kind === 'required').length, 4);
+ assert.equal(boxes.filter((box) => box.kind === 'clearance').length, 32);
+ assert.equal(boxes.find((box) => box.label === 'support').to[1], 9);
+ assert.equal(Math.min(...boxes.filter((box) => box.kind === 'clearance').map((box) => box.from[0])), -24);
+ assert.throws(() => h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'surface',
+ placement: 'directional_clearance', surface_radius: 0, blocks: '' }), /requires a block/);
+});
+
+test('directional center guides union overlapping above-surface checks and enforce preview size limits', () => {
+ const h = harness();
+ const entry = h.readBlockRequirementForm({ ...wallForm(), type: 'center_support', support: 'slab',
+ placement: 'directional_clearance', surface_radius: 1 });
+ const boxes = h.buildBlockRequirementGuideBoxes(entry);
+ const front = boxes.filter((box) => box.kind === 'clearance' && box.from[0] === -8 && box.from[2] === 8);
+ assert.equal(front.length, 1);
+ assert.equal(front[0].from[1], 0);
+ assert.equal(front[0].to[1], 40);
+ assert.throws(() => h.buildBlockRequirementGuideBoxes(h.readBlockRequirementForm(wallForm({ width_min: 32,
+ clearance_width: 32, clearance_depth: 32, clearance_height: 32 }))), /4096/);
+});
+
+test('editing replaces only target guides, persists metadata and removal overrides previously exported requirements', () => {
+ const h = harness();
+ h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm()));
+ h.rebuildBlockRequirementGuides('second', h.readBlockRequirementForm(wallForm()));
+ const second = h.Group.all.find((group) => group.name.endsWith('second'));
+ const first = h.Group.all.find((group) => group.name.endsWith('first'));
+ const firstCube = h.Cube.all[0];
+ h.rebuildBlockRequirementGuides('first', h.readBlockRequirementForm(wallForm({ width_min: 1 })));
+ assert.equal(h.Group.all.length, 2);
+ assert.ok(h.Group.all.includes(first));
+ assert.ok(h.Cube.all.includes(firstCube));
+ assert.ok(h.Group.all.includes(second));
+ assert.ok(h.Cube.all.every((cube) => cube.export === false));
+ assert.ok(h.Group.all.every((group) => group.export === false));
+ assert.equal(h.getAuthoredBlockRequirement('first').requirement.width.min, 1);
+ assert.equal(h.edits.at(-1).aspects.block_requirement_edit, true);
+ assert.equal(h.edits.at(-1).post.block_requirement_edit, true);
+ h.rebuildBlockRequirementGuides('first', null);
+ assert.equal(h.Group.all.length, 1);
+ assert.equal(h.getAuthoredBlockRequirement('first').requirement, null);
+});
+
+test('live panel coalesces input bursts and leaves invalid edits out of the project and undo history', () => {
+ const h = harness();
+ h.createBlockRequirementPanel();
+ const view = h.getPanel().vue;
+ view.load('test');
+ assert.equal(h.edits.length, 0, 'Opening the editor does not add a requirement');
+ view.flush(true);
+ const cube = h.Cube.all[0];
+ const field = view.form.clearance_depth;
+ view.input('clearance_depth', field, { target: { value: '2' } });
+ view.input('clearance_depth', field, { target: { value: '3' } });
+ assert.equal(h.timers.size, 1);
+ assert.equal(h.edits.length, 1);
+ for (const callback of h.timers.values()) callback();
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.edits.length, 2, 'The burst creates one undo entry');
+ assert.equal(h.getAuthoredBlockRequirement('test').requirement.clearance.depth, 3);
+ assert.ok(h.Cube.all.includes(cube));
+ view.input('clearance_depth', field, { target: { value: '' } });
+ view.flush();
+ assert.match(view.error, /last valid preview/);
+ assert.equal(h.timers.size, 0);
+ assert.equal(h.edits.length, 2);
+ assert.equal(h.getAuthoredBlockRequirement('test').requirement.clearance.depth, 3);
+ view.input('clearance_depth', field, { target: { value: '3' } });
+ view.flush();
+ assert.equal(view.error, '');
+ assert.equal(h.edits.length, 2, 'Unchanged values do not add undo entries');
+});
+
+test('panel switches animations safely, resets on project changes and reloads restored undo metadata', () => {
+ const h = harness();
+ h.context.registration.onload();
+ h.createBlockRequirementPanel();
+ const view = h.getPanel().vue;
+ view.load('first');
+ view.input('clearance_depth', view.form.clearance_depth, { target: { value: '4' } });
+ view.selectId({ target: { value: 'second' } });
+ assert.equal(h.getAuthoredBlockRequirement('first').requirement.clearance.depth, 4);
+ assert.equal(view.animationId, 'second');
+ assert.equal(h.getAuthoredBlockRequirement('second'), null);
+ view.flush(true);
+ const before = h.project.animationdirector_block_requirements;
+ view.input('clearance_depth', view.form.clearance_depth, { target: { value: '5' } });
+ view.flush();
+ h.events.get('load_undo_save')({ save: { animationdirector_block_requirements: before } });
+ h.events.get('undo')();
+ assert.equal(view.values.clearance_depth, 1);
+ view.input('clearance_depth', view.form.clearance_depth, { target: { value: '7' } });
+ h.context.Project = { uuid: 'another-project' };
+ h.events.get('select_project')();
+ assert.equal(h.timers.size, 0);
+ assert.equal(view.animationId, '');
+ assert.equal(h.context.Project.animationdirector_block_requirements, undefined);
+ assert.equal(JSON.parse(h.project.animationdirector_block_requirements).second.requirement.clearance.depth, 1);
+});
+
+test('panel conditionally exposes wall and center-support sections without exporting preview settings', () => {
+ const h = harness();
+ h.createBlockRequirementPanel();
+ const panel = h.getPanel();
+ const view = panel.vue;
+ view.load('test');
+ const titles = () => Array.from(panel.options.component.computed.sections.call(view), (section) => section.title);
+ assert.ok(titles().includes('Wall structure'));
+ assert.ok(!titles().includes('Center support'));
+ view.values.type = 'center_support';
+ view.values.placement = 'surface_footprint';
+ assert.ok(titles().includes('Center support'));
+ assert.ok(!titles().includes('Wall structure'));
+ assert.ok(!titles().includes('Directional clearance'));
+ view.flush(true);
+ assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'center_support');
+ view.remove();
+ assert.equal(h.getAuthoredBlockRequirement('test').requirement, null);
+ assert.equal(h.Cube.all.length, 0);
+});
+
+test('guide material survives face rebuilds and metadata participates in Blockbench undo saves', () => {
+ const h = harness();
+ h.context.registration.onload();
+ const before = {};
+ h.events.get('create_undo_save')({ save: before, aspects: { block_requirement_edit: true } });
+ h.rebuildBlockRequirementGuides('test', h.readBlockRequirementForm(wallForm()));
+ const post = {};
+ h.events.get('create_undo_save')({ save: post, aspects: { block_requirement_edit: true } });
+ for (const kind of ['required', 'clearance']) {
+ const cube = h.Cube.all.find((candidate) => candidate.name.includes(`_${kind}_`));
+ assert.equal(cube.mesh.material.opacity, 0.22);
+ assert.equal(cube.mesh.material.transparent, true);
+ cube.mesh.material = {};
+ h.previewEvents.get('update_faces')({ element: cube });
+ assert.equal(cube.mesh.material.opacity, 0.22);
+ }
+ h.events.get('load_undo_save')({ save: before });
+ assert.equal(h.getAuthoredBlockRequirement('test'), null);
+ h.events.get('load_undo_save')({ save: post });
+ assert.equal(h.getAuthoredBlockRequirement('test').requirement.type, 'wall');
+});
+
+test('generator includes only the selected animation requirement and strips guide bones from exports', () => {
+ const h = harness();
+ const entry = h.readBlockRequirementForm(wallForm());
+ h.rebuildBlockRequirementGuides('test', entry);
+ const analysis = { actorIndices: [1, 2], actorLabels: new Map([[1, 'player'], [2, 'wolf']]),
+ stages: [{ stageNumber: 1, name: 'test.p1', loop: true, cycleSeconds: 1 }], warnings: [] };
+ function generate(id) {
+ h.showAnimationDefinitionReviewDialog({ id }, analysis);
+ const dialog = h.dialogs.at(-1);
+ const values = Object.fromEntries(Object.entries(dialog.form)
+ .filter(([, field]) => field && typeof field === 'object' && 'value' in field).map(([key, field]) => [key, field.value]));
+ assert.equal(dialog.onConfirm(values), true);
+ return h.exports.at(-1);
+ }
+ assert.deepEqual(generate('test').block_requirements, JSON.parse(JSON.stringify(entry.requirement)));
+ assert.equal(generate('other').block_requirements, undefined);
+ h.rebuildBlockRequirementGuides('test', null);
+ assert.equal(generate('test').block_requirements, undefined);
+ assert.equal(h.errors.length, 0);
+ const clip = { bones: { __ad_block_requirement_test: { rotation: [1, 2, 3] }, actor1_body: { position: [0, 1, 2] } }, sound_effects: { 0: 'sound' } };
+ h.stripBlockRequirementGuideBones(clip);
+ assert.deepEqual(Object.keys(clip.bones), ['actor1_body']);
+ assert.ok(clip.sound_effects);
+ h.context.Format = { codec: { compile: () => ({ 'minecraft:geometry': [{ bones: [
+ { name: '__ad_block_requirement_test' }, { name: 'renamed_guide', parent: '__ad_block_requirement_test' },
+ { name: 'actor1_body' },
+ ] }] }) } };
+ h.context.Codecs = {};
+ assert.deepEqual(Array.from(h.compileGeckoModelJson()['minecraft:geometry'][0].bones, (bone) => bone.name), ['actor1_body']);
+});