From 5ca91e3e34a1e1439099e8efffabf96b72cd536d Mon Sep 17 00:00:00 2001 From: Ella-AWS <111664173+EllaCoat@users.noreply.github.com> Date: Sun, 30 Aug 2026 01:10:49 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20=E8=B2=BC=E3=82=8A=E4=BB=98?= =?UTF-8?q?=E3=81=91=E3=81=9FCube=E3=81=AE=E8=A4=87=E8=BB=B8=E5=9B=9E?= =?UTF-8?q?=E8=BB=A2=E3=82=92=E5=AE=89=E5=85=A8=E3=81=AB=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/formats/blueprint/index.ts | 5 ++ .../blueprint/rotationConstraintGuard.ts | 27 ++++++ src/mods/outlinerPasteMod.ts | 37 ++++++++ src/systems/util.ts | 27 +++--- src/tests/cubeRotationValidity.test.ts | 54 ++++++++++++ src/tests/pastedCubeRotation.test.ts | 88 +++++++++++++++++++ 6 files changed, 223 insertions(+), 15 deletions(-) create mode 100644 src/formats/blueprint/rotationConstraintGuard.ts create mode 100644 src/mods/outlinerPasteMod.ts create mode 100644 src/tests/cubeRotationValidity.test.ts create mode 100644 src/tests/pastedCubeRotation.test.ts diff --git a/src/formats/blueprint/index.ts b/src/formats/blueprint/index.ts index 20f0b791..350bd077 100644 --- a/src/formats/blueprint/index.ts +++ b/src/formats/blueprint/index.ts @@ -17,6 +17,7 @@ import { sanitizeStorageKey } from '../../util/minecraftUtil' import { Variant } from '../../variants' import { BLUEPRINT_CODEC } from './codec' import FormatPageSvelte from './formatPage.svelte' +import { rotationConstraintsAreSuspended } from './rotationConstraintGuard' import type { BlueprintSettings } from './settings' import * as blueprintSettings from './settings' @@ -455,6 +456,10 @@ export function updateRotationConstraints() { console.error('Animated Java Blueprint format is not registered!') return } + if (rotationConstraintsAreSuspended()) { + format.rotation_limit = false + return + } if (!projectTargetVersionIsAtLeast('1.21.11')) { // Rotation is limited to one axis, and between -45 and 45 degrees on versions before 1.21.11 diff --git a/src/formats/blueprint/rotationConstraintGuard.ts b/src/formats/blueprint/rotationConstraintGuard.ts new file mode 100644 index 00000000..ea0f8de1 --- /dev/null +++ b/src/formats/blueprint/rotationConstraintGuard.ts @@ -0,0 +1,27 @@ +interface RotationLimitState { + rotation_limit: boolean +} + +let suspensionDepth = 0 + +export function rotationConstraintsAreSuspended() { + return suspensionDepth > 0 +} + +export function runWithRotationConstraintsSuspended( + state: RotationLimitState, + action: () => void, + restoreConstraints: () => void +) { + const previousRotationLimit = state.rotation_limit + suspensionDepth += 1 + state.rotation_limit = false + + try { + action() + } finally { + state.rotation_limit = previousRotationLimit + suspensionDepth -= 1 + if (suspensionDepth === 0) restoreConstraints() + } +} diff --git a/src/mods/outlinerPasteMod.ts b/src/mods/outlinerPasteMod.ts new file mode 100644 index 00000000..5286dc44 --- /dev/null +++ b/src/mods/outlinerPasteMod.ts @@ -0,0 +1,37 @@ +import { registerPatch } from 'blockbench-patch-manager' +import { + activeProjectIsBlueprintFormat, + BLUEPRINT_FORMAT, + updateRotationConstraints, +} from '../formats/blueprint' +import { runWithRotationConstraintsSuspended } from '../formats/blueprint/rotationConstraintGuard' + +registerPatch({ + id: `animated_java:preserve-pasted-cube-rotations`, + + apply: () => { + const originalPasteOutliner = Clipbench.pasteOutliner + + Clipbench.pasteOutliner = function (event: Event) { + if (!activeProjectIsBlueprintFormat()) return originalPasteOutliner(event) + + const format = BLUEPRINT_FORMAT.get() + if (!format) return originalPasteOutliner(event) + + // Blockbench applies the format-wide Cube rotation limit during paste, which + // discards every rotation axis except the first non-zero one. Keep the + // source rotation intact; AJ still outlines and rejects invalid Cubes. + runWithRotationConstraintsSuspended( + format, + () => originalPasteOutliner(event), + updateRotationConstraints + ) + } + + return { originalPasteOutliner } + }, + + revert: ({ originalPasteOutliner }) => { + Clipbench.pasteOutliner = originalPasteOutliner + }, +}) diff --git a/src/systems/util.ts b/src/systems/util.ts index d075bd2b..82fa16cd 100644 --- a/src/systems/util.ts +++ b/src/systems/util.ts @@ -98,29 +98,26 @@ export function isCubeValid(cube: Cube): '1.21.6+' | 'valid' | 'invalid' { return 'valid' } - const totalRotation = cube.rotation[0] + cube.rotation[1] + cube.rotation[2] + const activeRotations = cube.rotation.filter(rotation => rotation !== 0) + if (activeRotations.length === 0) return 'valid' + if (activeRotations.length > 1) return 'invalid' - if (totalRotation === 0) return 'valid' + const rotation = activeRotations[0] - const isSingleAxisRotation = - totalRotation === cube.rotation[0] || - totalRotation === cube.rotation[1] || - totalRotation === cube.rotation[2] - - if (isSingleAxisRotation && projectTargetVersionIsAtLeast('1.21.6')) { + if (projectTargetVersionIsAtLeast('1.21.6')) { // Rotation values still need to be within -45 and 45 degrees - if (totalRotation <= 45 && totalRotation >= -45) return '1.21.6+' + if (rotation <= 45 && rotation >= -45) return '1.21.6+' else return 'invalid' } const isRotationInAllowedSteps = - totalRotation === -45 || - totalRotation === -22.5 || - totalRotation === 0 || - totalRotation === 22.5 || - totalRotation === 45 + rotation === -45 || + rotation === -22.5 || + rotation === 0 || + rotation === 22.5 || + rotation === 45 - if (isSingleAxisRotation && isRotationInAllowedSteps) return 'valid' + if (isRotationInAllowedSteps) return 'valid' return 'invalid' } diff --git a/src/tests/cubeRotationValidity.test.ts b/src/tests/cubeRotationValidity.test.ts new file mode 100644 index 00000000..3ff6dc35 --- /dev/null +++ b/src/tests/cubeRotationValidity.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +let targetVersion = '1.20.4' + +vi.mock('../formats/blueprint', () => ({ + projectTargetVersionIsAtLeast(version: string) { + const actual = targetVersion.split('.').map(Number) + const expected = version.split('.').map(Number) + for (let index = 0; index < Math.max(actual.length, expected.length); index += 1) { + const actualPart = actual[index] ?? 0 + const expectedPart = expected[index] ?? 0 + if (actualPart !== expectedPart) return actualPart > expectedPart + } + return true + }, +})) + +import { isCubeValid } from '../systems/util' + +function cubeWithRotation(rotation: [number, number, number]) { + return { rotation } as Cube +} + +describe('Cube rotation validity', () => { + beforeEach(() => { + targetVersion = '1.20.4' + }) + + it('rejects multi-axis rotations even when their values cancel out', () => { + expect(isCubeValid(cubeWithRotation([45, -45, 0]))).toBe('invalid') + expect(isCubeValid(cubeWithRotation([22.5, 0, -22.5]))).toBe('invalid') + }) + + it('keeps the pre-1.21.6 single-axis step rules', () => { + expect(isCubeValid(cubeWithRotation([0, 0, 0]))).toBe('valid') + expect(isCubeValid(cubeWithRotation([22.5, 0, 0]))).toBe('valid') + expect(isCubeValid(cubeWithRotation([10, 0, 0]))).toBe('invalid') + }) + + it('allows arbitrary single-axis angles within the 1.21.6 range', () => { + targetVersion = '1.21.6' + + expect(isCubeValid(cubeWithRotation([10, 0, 0]))).toBe('1.21.6+') + expect(isCubeValid(cubeWithRotation([0, -45, 0]))).toBe('1.21.6+') + expect(isCubeValid(cubeWithRotation([0, 0, 45.1]))).toBe('invalid') + expect(isCubeValid(cubeWithRotation([10, 10, 0]))).toBe('invalid') + }) + + it('keeps unrestricted rotations valid on 1.21.11 and later', () => { + targetVersion = '1.21.11' + + expect(isCubeValid(cubeWithRotation([90, -60, 30]))).toBe('valid') + }) +}) diff --git a/src/tests/pastedCubeRotation.test.ts b/src/tests/pastedCubeRotation.test.ts new file mode 100644 index 00000000..a922a58f --- /dev/null +++ b/src/tests/pastedCubeRotation.test.ts @@ -0,0 +1,88 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it, vi } from 'vitest' +import { + rotationConstraintsAreSuspended, + runWithRotationConstraintsSuspended, +} from '../formats/blueprint/rotationConstraintGuard' + +const OUTLINER_PASTE_MOD_SOURCE = readFileSync( + new URL('../mods/outlinerPasteMod.ts', import.meta.url), + 'utf8' +) +const BLUEPRINT_FORMAT_SOURCE = readFileSync( + new URL('../formats/blueprint/index.ts', import.meta.url), + 'utf8' +) + +describe('pasted Cube rotation preservation', () => { + it('keeps pasted and existing multi-axis rotations while restoring the edit constraint', () => { + const format = { rotation_limit: true } + const cubes = [{ rotation: [22.5, 15, 0] }, { rotation: [-10, 0, 30] }] + const originalRotations = cubes.map(cube => [...cube.rotation]) + const restoreConstraints = vi.fn(() => { + format.rotation_limit = true + }) + + runWithRotationConstraintsSuspended( + format, + () => { + expect(rotationConstraintsAreSuspended()).toBe(true) + if (format.rotation_limit) { + for (const cube of cubes) { + const axis = cube.rotation.findIndex(rotation => rotation !== 0) + const angle = cube.rotation[axis] + cube.rotation.fill(0) + cube.rotation[axis] = angle + } + } + }, + restoreConstraints + ) + + expect(cubes.map(cube => cube.rotation)).toEqual(originalRotations) + expect(format.rotation_limit).toBe(true) + expect(rotationConstraintsAreSuspended()).toBe(false) + expect(restoreConstraints).toHaveBeenCalledOnce() + }) + + it('keeps the constraint suspended when selection updates during paste', () => { + const format = { rotation_limit: true } + + runWithRotationConstraintsSuspended( + format, + () => { + format.rotation_limit = true + if (rotationConstraintsAreSuspended()) format.rotation_limit = false + expect(format.rotation_limit).toBe(false) + }, + () => { + format.rotation_limit = true + } + ) + }) + + it('restores the constraint when Blockbench paste throws', () => { + const format = { rotation_limit: true } + const restoreConstraints = vi.fn() + + expect(() => + runWithRotationConstraintsSuspended( + format, + () => { + throw new Error('paste failed') + }, + restoreConstraints + ) + ).toThrow('paste failed') + + expect(format.rotation_limit).toBe(true) + expect(rotationConstraintsAreSuspended()).toBe(false) + expect(restoreConstraints).toHaveBeenCalledOnce() + }) + + it('connects the paste patch and selection-update guard to the shared suspension state', () => { + expect(OUTLINER_PASTE_MOD_SOURCE).toContain('Clipbench.pasteOutliner = function') + expect(OUTLINER_PASTE_MOD_SOURCE).toContain('runWithRotationConstraintsSuspended(') + expect(BLUEPRINT_FORMAT_SOURCE).toContain('if (rotationConstraintsAreSuspended())') + }) +})