Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/formats/blueprint/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/formats/blueprint/rotationConstraintGuard.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
37 changes: 37 additions & 0 deletions src/mods/outlinerPasteMod.ts
Original file line number Diff line number Diff line change
@@ -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
},
})
27 changes: 12 additions & 15 deletions src/systems/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
54 changes: 54 additions & 0 deletions src/tests/cubeRotationValidity.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
88 changes: 88 additions & 0 deletions src/tests/pastedCubeRotation.test.ts
Original file line number Diff line number Diff line change
@@ -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())')
})
})
Loading