(),
{
variant: 'CLASSIC',
@@ -173,6 +188,8 @@ const props = withDefaults(
randomAnimationInterval: 8000,
transitionDuration: 0.2,
}),
+ armorPreview: false,
+ armorControls: true,
},
)
@@ -197,6 +214,7 @@ const isDarkPreviewTheme = computed(() => {
const classList = document.documentElement.classList
return classList.contains('dark-mode') || classList.contains('oled-mode')
})
+const showsArmorControls = computed(() => Boolean(props.armorPreview) && props.armorControls)
function getSubtitleLayoutRoot(element: HTMLElement) {
const elementChildren = Array.from(element.children).filter(
@@ -286,6 +304,18 @@ const { isModelLoaded, isTextureLoaded, modelCenter, modelSize, scene } = useSki
cleanupAnimationState,
})
+const armorConfig = defineModel
('armorConfig', {
+ default: createDefaultArmorPreviewConfig,
+})
+
+// Auto-fit is anchored to the load-time pose. Measuring again after an async
+// armor update could capture an arbitrary animation frame and jump on resize.
+useArmorPreview({
+ scene,
+ config: armorConfig,
+ enabled: toRef(props, 'armorPreview'),
+})
+
function syncDamageFlashShaderMaterials() {
syncDamageFlashShader(scene.value, damageFlashIntensity.value)
}
diff --git a/packages/ui/src/components/skin/index.ts b/packages/ui/src/components/skin/index.ts
index 7fff3d0..ddfef53 100644
--- a/packages/ui/src/components/skin/index.ts
+++ b/packages/ui/src/components/skin/index.ts
@@ -1,3 +1,5 @@
+export { default as ArmorPreviewControls } from './ArmorPreviewControls.vue'
+export { default as ArmorTrimPanel } from './ArmorTrimPanel.vue'
export { default as CapeButton } from './CapeButton.vue'
export { default as CapeLikeTextButton } from './CapeLikeTextButton.vue'
export { default as SkinButton } from './SkinButton.vue'
diff --git a/packages/ui/src/composables/index.ts b/packages/ui/src/composables/index.ts
index d49723c..8a74269 100644
--- a/packages/ui/src/composables/index.ts
+++ b/packages/ui/src/composables/index.ts
@@ -10,6 +10,15 @@ export * from './i18n'
export * from './i18n-debug'
export * from './page-leave-safety'
export * from './scroll-indicator'
+export type { ArmorPreviewConfig } from './skin-rendering/armor-preview-types'
+export {
+ ARMOR_SLOTS,
+ ARMOR_TRIM_MATERIALS,
+ ARMOR_TRIM_PATTERNS,
+ armorMaterialsForSlot,
+ cloneArmorPreviewConfig,
+ createDefaultArmorPreviewConfig,
+} from './skin-rendering/armor-preview-types'
export * from './sticky-observer'
export * from './terminal'
export * from './use-batch-drop'
diff --git a/packages/ui/src/composables/skin-rendering/armor-preview-assets.ts b/packages/ui/src/composables/skin-rendering/armor-preview-assets.ts
new file mode 100644
index 0000000..57c9ffc
--- /dev/null
+++ b/packages/ui/src/composables/skin-rendering/armor-preview-assets.ts
@@ -0,0 +1,105 @@
+import type {
+ ArmorMaterial,
+ ArmorSlot,
+ ArmorTrimMaterial,
+ ArmorTrimPattern,
+} from './armor-preview-types'
+
+const assetModules = import.meta.glob('../../assets/minecraft-armor/**/*.png', {
+ eager: true,
+ query: '?url',
+ import: 'default',
+})
+
+function asset(path: string): string {
+ const url = assetModules[`../../assets/minecraft-armor/${path}.png`]
+ if (!url) throw new Error(`Missing Minecraft armor preview asset: ${path}`)
+ return url
+}
+
+const armorTextureName: Record = {
+ leather: 'leather',
+ chainmail: 'chainmail',
+ copper: 'copper',
+ gold: 'gold',
+ iron: 'iron',
+ diamond: 'diamond',
+ netherite: 'netherite',
+ turtle: 'turtle_scute',
+}
+
+const armorItemName: Record = {
+ leather: 'leather_default',
+ chainmail: 'chainmail',
+ copper: 'copper',
+ gold: 'golden',
+ iron: 'iron',
+ diamond: 'diamond',
+ netherite: 'netherite',
+ turtle: 'turtle',
+}
+
+const trimIngredientName: Record = {
+ amethyst: 'amethyst_shard',
+ copper: 'copper_ingot',
+ diamond: 'diamond',
+ emerald: 'emerald',
+ gold: 'gold_ingot',
+ iron: 'iron_ingot',
+ lapis: 'lapis_lazuli',
+ netherite: 'netherite_ingot',
+ quartz: 'quartz',
+ redstone: 'redstone',
+ resin: 'resin_brick',
+}
+
+const darkerTrimMaterials = new Set([
+ 'copper',
+ 'diamond',
+ 'gold',
+ 'iron',
+ 'netherite',
+])
+
+export type ArmorTextureLayer = 'humanoid' | 'leggings'
+
+export function getArmorTextureUrl(material: ArmorMaterial, layer: ArmorTextureLayer): string {
+ return asset(`entity/${layer}/${armorTextureName[material]}`)
+}
+
+export function getLeatherOverlayUrl(layer: ArmorTextureLayer): string {
+ return asset(`entity/${layer}/leather_overlay`)
+}
+
+export function getArmorItemIcon(material: ArmorMaterial, slot: ArmorSlot): string {
+ if (material === 'turtle') return asset('item/turtle_helmet')
+ return asset(`item/${armorItemName[material]}_${slot}`)
+}
+
+export function getArmorSlotIcon(slot: ArmorSlot): string {
+ return asset(`slot/${slot}`)
+}
+
+export function getTrimPatternIcon(pattern: ArmorTrimPattern): string {
+ return asset(`item/${pattern}_armor_trim_smithing_template`)
+}
+
+export function getTrimMaterialIcon(material: ArmorTrimMaterial): string {
+ return asset(`item/${trimIngredientName[material]}`)
+}
+
+export function getTrimPatternTextureUrl(
+ pattern: ArmorTrimPattern,
+ layer: ArmorTextureLayer,
+): string {
+ return asset(`trim/${layer}/${pattern}`)
+}
+
+export function getTrimPaletteUrl(material: ArmorTrimMaterial, darker: boolean): string {
+ const suffix = darker && darkerTrimMaterials.has(material) ? '_darker' : ''
+ return asset(`trim/palettes/${material}${suffix}`)
+}
+
+export function getTrimSourcePaletteUrl(): string {
+ return asset('trim/palettes/trim_palette')
+}
diff --git a/packages/ui/src/composables/skin-rendering/armor-preview-types.test.ts b/packages/ui/src/composables/skin-rendering/armor-preview-types.test.ts
new file mode 100644
index 0000000..c13f7dc
--- /dev/null
+++ b/packages/ui/src/composables/skin-rendering/armor-preview-types.test.ts
@@ -0,0 +1,56 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { ref } from 'vue'
+
+import {
+ ARMOR_SLOTS,
+ ARMOR_TRIM_MATERIALS,
+ ARMOR_TRIM_PATTERNS,
+ armorMaterialsForSlot,
+ cloneArmorPreviewConfig,
+ createDefaultArmorPreviewConfig,
+} from './armor-preview-types.ts'
+
+test('armor preview exposes every supported slot, trim pattern, and trim material', () => {
+ assert.deepEqual(ARMOR_SLOTS, ['helmet', 'chestplate', 'leggings', 'boots'])
+ assert.equal(ARMOR_TRIM_PATTERNS.length, 18)
+ assert.equal(ARMOR_TRIM_MATERIALS.length, 11)
+})
+
+test('armor slots start unequipped and keep independent selections', () => {
+ const config = createDefaultArmorPreviewConfig()
+
+ for (const slot of ARMOR_SLOTS) {
+ assert.equal(config[slot].material, null)
+ assert.equal(config[slot].trimPattern, null)
+ assert.equal(config[slot].trimMaterial, 'iron')
+ }
+
+ config.helmet.material = 'diamond'
+ assert.equal(config.chestplate.material, null)
+})
+
+test('turtle armor is only available for the helmet slot', () => {
+ assert.ok(armorMaterialsForSlot('helmet').includes('turtle'))
+ assert.ok(!armorMaterialsForSlot('chestplate').includes('turtle'))
+ assert.ok(!armorMaterialsForSlot('leggings').includes('turtle'))
+ assert.ok(!armorMaterialsForSlot('boots').includes('turtle'))
+})
+
+test('clones a config that lives behind a reactive ref, deeply', () => {
+ const config = createDefaultArmorPreviewConfig()
+ config.helmet.material = 'iron'
+ config.helmet.trimPattern = 'bolt'
+
+ // The value a caller holds has been through `ref()`, which `structuredClone`
+ // refuses to copy at all.
+ const clone = cloneArmorPreviewConfig(ref(config).value)
+
+ assert.deepEqual(clone, config)
+ assert.notEqual(clone, config)
+ assert.notEqual(clone.helmet, config.helmet)
+
+ clone.helmet.material = null
+ assert.equal(config.helmet.material, 'iron')
+})
diff --git a/packages/ui/src/composables/skin-rendering/armor-preview-types.ts b/packages/ui/src/composables/skin-rendering/armor-preview-types.ts
new file mode 100644
index 0000000..ca6714a
--- /dev/null
+++ b/packages/ui/src/composables/skin-rendering/armor-preview-types.ts
@@ -0,0 +1,99 @@
+export const ARMOR_SLOTS = ['helmet', 'chestplate', 'leggings', 'boots'] as const
+export type ArmorSlot = (typeof ARMOR_SLOTS)[number]
+
+export const ARMOR_MATERIALS = [
+ 'leather',
+ 'chainmail',
+ 'copper',
+ 'gold',
+ 'iron',
+ 'diamond',
+ 'netherite',
+ 'turtle',
+] as const
+export type ArmorMaterial = (typeof ARMOR_MATERIALS)[number]
+
+export const ARMOR_TRIM_PATTERNS = [
+ 'bolt',
+ 'coast',
+ 'dune',
+ 'eye',
+ 'flow',
+ 'host',
+ 'raiser',
+ 'rib',
+ 'sentry',
+ 'shaper',
+ 'silence',
+ 'snout',
+ 'spire',
+ 'tide',
+ 'vex',
+ 'ward',
+ 'wayfinder',
+ 'wild',
+] as const
+export type ArmorTrimPattern = (typeof ARMOR_TRIM_PATTERNS)[number]
+
+export const ARMOR_TRIM_MATERIALS = [
+ 'amethyst',
+ 'copper',
+ 'diamond',
+ 'emerald',
+ 'gold',
+ 'iron',
+ 'lapis',
+ 'netherite',
+ 'quartz',
+ 'redstone',
+ 'resin',
+] as const
+export type ArmorTrimMaterial = (typeof ARMOR_TRIM_MATERIALS)[number]
+
+export interface ArmorPiecePreview {
+ material: ArmorMaterial | null
+ trimPattern: ArmorTrimPattern | null
+ trimMaterial: ArmorTrimMaterial
+}
+
+export type ArmorPreviewConfig = Record
+
+const DEFAULT_TRIM_MATERIAL: ArmorTrimMaterial = 'iron'
+
+export function createDefaultArmorPreviewConfig(): ArmorPreviewConfig {
+ return Object.fromEntries(
+ ARMOR_SLOTS.map((slot) => [
+ slot,
+ {
+ material: null,
+ trimPattern: null,
+ trimMaterial: DEFAULT_TRIM_MATERIAL,
+ },
+ ]),
+ ) as ArmorPreviewConfig
+}
+
+/**
+ * Copies a config piece by piece. `structuredClone` would read better, but a
+ * config that has been through `ref()` or `reactive()` is a proxy and it refuses
+ * to clone one, which is exactly the value a caller holds.
+ */
+export function cloneArmorPreviewConfig(config: ArmorPreviewConfig): ArmorPreviewConfig {
+ return Object.fromEntries(
+ ARMOR_SLOTS.map((slot) => {
+ const piece = config[slot]
+ return [
+ slot,
+ {
+ material: piece?.material ?? null,
+ trimPattern: piece?.trimPattern ?? null,
+ trimMaterial: piece?.trimMaterial ?? DEFAULT_TRIM_MATERIAL,
+ },
+ ]
+ }),
+ ) as ArmorPreviewConfig
+}
+
+export function armorMaterialsForSlot(slot: ArmorSlot): readonly ArmorMaterial[] {
+ return slot === 'helmet' ? ARMOR_MATERIALS : ARMOR_MATERIALS.filter((item) => item !== 'turtle')
+}
diff --git a/packages/ui/src/composables/skin-rendering/use-armor-preview.ts b/packages/ui/src/composables/skin-rendering/use-armor-preview.ts
new file mode 100644
index 0000000..4fd0287
--- /dev/null
+++ b/packages/ui/src/composables/skin-rendering/use-armor-preview.ts
@@ -0,0 +1,55 @@
+import type * as THREE from 'three'
+import { onUnmounted, type Ref, watch } from 'vue'
+
+import {
+ applyPreparedArmorPreview,
+ prepareArmorPreview,
+ removeArmorPreview,
+} from '../../utils/webgl/armor-rendering'
+import { ARMOR_SLOTS, type ArmorPreviewConfig } from './armor-preview-types'
+
+function cloneArmorConfig(config: ArmorPreviewConfig): ArmorPreviewConfig {
+ return Object.fromEntries(
+ ARMOR_SLOTS.map((slot) => [slot, { ...config[slot] }]),
+ ) as ArmorPreviewConfig
+}
+
+export function useArmorPreview({
+ scene,
+ config,
+ enabled,
+}: {
+ scene: Ref
+ config: Ref
+ enabled: Ref
+}) {
+ let applyVersion = 0
+
+ watch(
+ [scene, enabled, () => JSON.stringify(config.value)],
+ async ([root, isEnabled]) => {
+ const version = ++applyVersion
+ if (!root) return
+
+ if (!isEnabled) {
+ removeArmorPreview(root)
+ return
+ }
+
+ const snapshot = cloneArmorConfig(config.value)
+ try {
+ const prepared = await prepareArmorPreview(snapshot)
+ if (version !== applyVersion || scene.value !== root) return
+ applyPreparedArmorPreview(root, snapshot, prepared)
+ } catch (error) {
+ console.error('Failed to update armor preview:', error)
+ }
+ },
+ { immediate: true },
+ )
+
+ onUnmounted(() => {
+ applyVersion++
+ if (scene.value) removeArmorPreview(scene.value)
+ })
+}
diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts
index 1762465..e7c1598 100644
--- a/packages/ui/src/utils/index.ts
+++ b/packages/ui/src/utils/index.ts
@@ -15,3 +15,4 @@ export * from './tag-messages'
export * from './truncate'
export * from './version-compatibility'
export * from './vue-children'
+export * from './webgl/skin-rendering'
diff --git a/packages/ui/src/utils/webgl/armor-geometry.test.ts b/packages/ui/src/utils/webgl/armor-geometry.test.ts
new file mode 100644
index 0000000..3f7d9e5
--- /dev/null
+++ b/packages/ui/src/utils/webgl/armor-geometry.test.ts
@@ -0,0 +1,222 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import * as THREE from 'three'
+
+import { createArmorGeometry } from './armor-geometry.ts'
+
+function geometrySize(geometry: THREE.BufferGeometry): THREE.Vector3 {
+ geometry.computeBoundingBox()
+ return geometry.boundingBox!.getSize(new THREE.Vector3())
+}
+
+function geometryBounds(geometry: THREE.BufferGeometry): THREE.Box3 {
+ geometry.computeBoundingBox()
+ return geometry.boundingBox!
+}
+
+function uvBoundsForNormal(
+ geometry: THREE.BufferGeometry,
+ normalKey: string,
+): { minU: number; maxU: number; minV: number; maxV: number } {
+ const normal = geometry.getAttribute('normal') as THREE.BufferAttribute
+ const uv = geometry.getAttribute('uv') as THREE.BufferAttribute
+ const values: Array<[number, number]> = []
+ for (let index = 0; index < normal.count; index++) {
+ const key = `${Math.round(normal.getX(index))},${Math.round(normal.getY(index))},${Math.round(normal.getZ(index))}`
+ if (key === normalKey) values.push([uv.getX(index) * 64, uv.getY(index) * 32])
+ }
+ assert.ok(values.length > 0, `missing face with normal ${normalKey}`)
+ return {
+ minU: Math.min(...values.map(([u]) => u)),
+ maxU: Math.max(...values.map(([u]) => u)),
+ minV: Math.min(...values.map(([, v]) => v)),
+ maxV: Math.max(...values.map(([, v]) => v)),
+ }
+}
+
+function assertUvBounds(
+ actual: ReturnType,
+ expected: ReturnType,
+): void {
+ for (const key of ['minU', 'maxU', 'minV', 'maxV'] as const) {
+ assert.ok(Math.abs(actual[key] - expected[key]) < 1e-5, `${key}: ${actual[key]}`)
+ }
+}
+
+function assertUvHorizontalBounds(
+ geometry: THREE.BufferGeometry,
+ normalKey: string,
+ minU: number,
+ maxU: number,
+): void {
+ const actual = uvBoundsForNormal(geometry, normalKey)
+ assert.ok(Math.abs(actual.minU - minU) < 1e-5, `minU: ${actual.minU}`)
+ assert.ok(Math.abs(actual.maxU - maxU) < 1e-5, `maxU: ${actual.maxU}`)
+}
+
+function uAtFaceEdge(
+ geometry: THREE.BufferGeometry,
+ normalKey: string,
+ axis: 'x' | 'z',
+ edge: 'min' | 'max',
+): number {
+ const position = geometry.getAttribute('position') as THREE.BufferAttribute
+ const normal = geometry.getAttribute('normal') as THREE.BufferAttribute
+ const uv = geometry.getAttribute('uv') as THREE.BufferAttribute
+ const candidates: Array<{ coordinate: number; u: number }> = []
+ for (let index = 0; index < normal.count; index++) {
+ const key = `${Math.round(normal.getX(index))},${Math.round(normal.getY(index))},${Math.round(normal.getZ(index))}`
+ if (key !== normalKey) continue
+ candidates.push({
+ coordinate: axis === 'x' ? position.getX(index) : position.getZ(index),
+ u: uv.getX(index) * 64,
+ })
+ }
+ assert.ok(candidates.length > 0, `missing face with normal ${normalKey}`)
+ const target = Math[edge](...candidates.map(({ coordinate }) => coordinate))
+ const values = candidates
+ .filter(({ coordinate }) => Math.abs(coordinate - target) < 1e-7)
+ .map(({ u }) => u)
+ assert.ok(values.length > 0)
+ assert.ok(values.every((u) => Math.abs(u - values[0]) < 1e-5))
+ return values[0]
+}
+
+test('outer armor expands every side by one model pixel', () => {
+ const source = new THREE.BoxGeometry(8 / 16, 12 / 16, 4 / 16)
+ const size = geometrySize(createArmorGeometry(source, 'outer', 'body'))
+
+ assert.ok(Math.abs(size.x - 10 / 16) < 1e-7)
+ assert.ok(Math.abs(size.y - 14 / 16) < 1e-7)
+ assert.ok(Math.abs(size.z - 6 / 16) < 1e-7)
+})
+
+test('leggings legs use the inner-model dilation with the vanilla leg reduction', () => {
+ const source = new THREE.BoxGeometry(4 / 16, 12 / 16, 4 / 16)
+ const size = geometrySize(createArmorGeometry(source, 'leggings', 'rightLeg'))
+
+ assert.ok(Math.abs(size.x - 4.8 / 16) < 1e-7)
+ assert.ok(Math.abs(size.y - 12.8 / 16) < 1e-7)
+ assert.ok(Math.abs(size.z - 4.8 / 16) < 1e-7)
+})
+
+test('slim player arms expand outwards to the classic armor bounds', () => {
+ const rightSource = new THREE.BoxGeometry(3 / 16, 12 / 16, 4 / 16).translate(11 / 32, 0, 0)
+ const leftSource = new THREE.BoxGeometry(3 / 16, 12 / 16, 4 / 16).translate(-11 / 32, 0, 0)
+ const right = geometryBounds(createArmorGeometry(rightSource, 'outer', 'rightArm'))
+ const left = geometryBounds(createArmorGeometry(leftSource, 'outer', 'leftArm'))
+
+ assert.ok(Math.abs(right.min.x - 3 / 16) < 1e-7)
+ assert.ok(Math.abs(right.max.x - 9 / 16) < 1e-7)
+ assert.ok(Math.abs(left.min.x + 9 / 16) < 1e-7)
+ assert.ok(Math.abs(left.max.x + 3 / 16) < 1e-7)
+ assert.ok(Math.abs(right.max.x - right.min.x - 6 / 16) < 1e-7)
+ assert.ok(Math.abs(left.max.x - left.min.x - 6 / 16) < 1e-7)
+ assert.ok(Math.abs(right.max.y - right.min.y - 14 / 16) < 1e-7)
+ assert.ok(Math.abs(right.max.z - right.min.z - 6 / 16) < 1e-7)
+})
+
+test('remaps player UV height from 64x64 skins to 64x32 armor textures', () => {
+ const source = new THREE.BoxGeometry(8 / 16, 8 / 16, 8 / 16)
+ const sourceUv = source.getAttribute('uv') as THREE.BufferAttribute
+ const expected = Array.from({ length: sourceUv.count }, (_, index) => sourceUv.getY(index) * 2)
+ const armorUv = createArmorGeometry(source, 'outer', 'head').getAttribute(
+ 'uv',
+ ) as THREE.BufferAttribute
+
+ for (let index = 0; index < armorUv.count; index++) {
+ assert.ok(Math.abs(armorUv.getY(index) - expected[index]) < 1e-7)
+ }
+})
+
+test('armor arms use vanilla mirrored UV regions on their physical outer and inner faces', () => {
+ const rightSource = new THREE.BoxGeometry(4 / 16, 12 / 16, 4 / 16)
+ const leftSource = rightSource.clone()
+ const leftPosition = leftSource.getAttribute('position') as THREE.BufferAttribute
+ const leftNormal = leftSource.getAttribute('normal') as THREE.BufferAttribute
+ const leftUv = leftSource.getAttribute('uv') as THREE.BufferAttribute
+ for (let index = 0; index < leftPosition.count; index++) {
+ leftPosition.setX(index, -leftPosition.getX(index))
+ leftNormal.setX(index, -leftNormal.getX(index))
+ // The authored GLTF left arm mirrors both its physical X coordinate and
+ // each face's source U direction relative to the right arm.
+ leftUv.setX(index, 1 - leftUv.getX(index))
+ }
+
+ const right = createArmorGeometry(rightSource, 'outer', 'rightArm')
+ const left = createArmorGeometry(leftSource, 'outer', 'leftArm')
+
+ // In the player GLTF, +X is the right arm's outside and -X is the left
+ // arm's outside. Vanilla mirror() maps both outside faces to U 40..44.
+ assertUvBounds(uvBoundsForNormal(right, '1,0,0'), {
+ minU: 40,
+ maxU: 44,
+ minV: 20,
+ maxV: 32,
+ })
+ assertUvBounds(uvBoundsForNormal(left, '-1,0,0'), {
+ minU: 40,
+ maxU: 44,
+ minV: 20,
+ maxV: 32,
+ })
+ assertUvBounds(uvBoundsForNormal(right, '-1,0,0'), {
+ minU: 48,
+ maxU: 52,
+ minV: 20,
+ maxV: 32,
+ })
+ assertUvBounds(uvBoundsForNormal(left, '1,0,0'), {
+ minU: 48,
+ maxU: 52,
+ minV: 20,
+ maxV: 32,
+ })
+
+ for (const normal of ['0,0,-1', '0,0,1', '0,-1,0', '0,1,0']) {
+ assertUvBounds(uvBoundsForNormal(left, normal), uvBoundsForNormal(right, normal))
+ }
+
+ // Both physical outer edges point to the start of the shared front region.
+ assert.ok(Math.abs(uAtFaceEdge(right, '0,0,-1', 'x', 'max') - 44) < 1e-5)
+ assert.ok(Math.abs(uAtFaceEdge(left, '0,0,-1', 'x', 'min') - 44) < 1e-5)
+ assert.ok(Math.abs(uAtFaceEdge(right, '0,0,-1', 'x', 'min') - 48) < 1e-5)
+ assert.ok(Math.abs(uAtFaceEdge(left, '0,0,-1', 'x', 'max') - 48) < 1e-5)
+
+ // The mirrored left outside face has the same front-to-back orientation as
+ // the right outside face after ModelPart.Polygon reverses its vertices.
+ assert.ok(Math.abs(uAtFaceEdge(right, '1,0,0', 'z', 'min') - 44) < 1e-5)
+ assert.ok(Math.abs(uAtFaceEdge(left, '-1,0,0', 'z', 'min') - 44) < 1e-5)
+ assert.ok(Math.abs(uAtFaceEdge(right, '1,0,0', 'z', 'max') - 40) < 1e-5)
+ assert.ok(Math.abs(uAtFaceEdge(left, '-1,0,0', 'z', 'max') - 40) < 1e-5)
+})
+
+test('left leg mirrors every face and swaps its physical side UV regions', () => {
+ const source = new THREE.BoxGeometry(4 / 16, 12 / 16, 4 / 16)
+ const sourceUv = source.getAttribute('uv') as THREE.BufferAttribute
+ const expectedV = Array.from({ length: sourceUv.count }, (_, index) => sourceUv.getY(index) * 2)
+ for (let index = 0; index < sourceUv.count; index++) {
+ sourceUv.setXY(index, sourceUv.getX(index) + 16 / 64, sourceUv.getY(index) + 32 / 64)
+ }
+
+ for (const layer of ['outer', 'leggings'] as const) {
+ const geometry = createArmorGeometry(source, layer, 'leftLeg')
+ const armorUv = geometry.getAttribute('uv') as THREE.BufferAttribute
+
+ for (let index = 0; index < armorUv.count; index++) {
+ assert.ok(Math.abs(armorUv.getY(index) - expectedV[index]) < 1e-7)
+ }
+
+ for (const [normal, minU, maxU] of [
+ ['0,0,-1', 4, 8],
+ ['0,0,1', 12, 16],
+ ['0,-1,0', 8, 12],
+ ['0,1,0', 4, 8],
+ ['-1,0,0', 0, 4],
+ ['1,0,0', 8, 12],
+ ] as const) {
+ assertUvHorizontalBounds(geometry, normal, minU, maxU)
+ }
+ }
+})
diff --git a/packages/ui/src/utils/webgl/armor-geometry.ts b/packages/ui/src/utils/webgl/armor-geometry.ts
new file mode 100644
index 0000000..ffdcec3
--- /dev/null
+++ b/packages/ui/src/utils/webgl/armor-geometry.ts
@@ -0,0 +1,209 @@
+import * as THREE from 'three'
+
+export type ArmorGeometryLayer = 'outer' | 'leggings'
+export type ArmorBodyPart = 'head' | 'body' | 'rightArm' | 'leftArm' | 'rightLeg' | 'leftLeg'
+
+const MODEL_PIXEL_SIZE = 1 / 16
+const OUTER_ARMOR_DILATION = MODEL_PIXEL_SIZE
+const LEGGINGS_ARMOR_DILATION = MODEL_PIXEL_SIZE / 2
+const LEG_DEFORMATION_REDUCTION = MODEL_PIXEL_SIZE / 10
+const CLASSIC_ARM_WIDTH = 4 * MODEL_PIXEL_SIZE
+
+interface UvBounds {
+ minU: number
+ maxU: number
+ minV: number
+ maxV: number
+}
+
+const ARMOR_ARM_UV_RECTS: Record = {
+ '0,0,-1': { minU: 44, maxU: 48, minV: 20, maxV: 32 },
+ '0,0,1': { minU: 52, maxU: 56, minV: 20, maxV: 32 },
+ '0,-1,0': { minU: 48, maxU: 52, minV: 16, maxV: 20 },
+ '0,1,0': { minU: 44, maxU: 48, minV: 16, maxV: 20 },
+ '-1,0,0': { minU: 48, maxU: 52, minV: 20, maxV: 32 },
+ '1,0,0': { minU: 40, maxU: 44, minV: 20, maxV: 32 },
+}
+
+const ARMOR_LEG_UV_RECTS: Record = {
+ '0,0,-1': { minU: 4, maxU: 8 },
+ '0,0,1': { minU: 12, maxU: 16 },
+ '0,-1,0': { minU: 8, maxU: 12 },
+ '0,1,0': { minU: 4, maxU: 8 },
+ '-1,0,0': { minU: 8, maxU: 12 },
+ '1,0,0': { minU: 0, maxU: 4 },
+}
+
+function armorLegUvRect(normal: string, bodyPart: ArmorBodyPart): UvBounds | undefined {
+ if (bodyPart !== 'leftLeg') return ARMOR_LEG_UV_RECTS[normal]
+ if (normal === '-1,0,0') return ARMOR_LEG_UV_RECTS['1,0,0']
+ if (normal === '1,0,0') return ARMOR_LEG_UV_RECTS['-1,0,0']
+ return ARMOR_LEG_UV_RECTS[normal]
+}
+
+function armorArmUvRect(normal: string, bodyPart: ArmorBodyPart): UvBounds | undefined {
+ if (bodyPart !== 'leftArm') return ARMOR_ARM_UV_RECTS[normal]
+
+ // Vanilla builds the left arm with CubeListBuilder.mirror(). In addition to
+ // reversing every face below, mirroring swaps the two X-facing regions: the
+ // physical outside of either arm uses U 40..44 and the inside uses U 48..52.
+ if (normal === '-1,0,0') return ARMOR_ARM_UV_RECTS['1,0,0']
+ if (normal === '1,0,0') return ARMOR_ARM_UV_RECTS['-1,0,0']
+ return ARMOR_ARM_UV_RECTS[normal]
+}
+
+function isArm(bodyPart: ArmorBodyPart): boolean {
+ return bodyPart === 'rightArm' || bodyPart === 'leftArm'
+}
+
+function isLeg(bodyPart: ArmorBodyPart): boolean {
+ return bodyPart === 'rightLeg' || bodyPart === 'leftLeg'
+}
+
+function normalKey(normal: THREE.BufferAttribute, index: number): string {
+ return `${Math.round(normal.getX(index))},${Math.round(normal.getY(index))},${Math.round(normal.getZ(index))}`
+}
+
+function remapArmUv(
+ uv: THREE.BufferAttribute,
+ normal: THREE.BufferAttribute,
+ bodyPart: ArmorBodyPart,
+): void {
+ const sourceBounds = new Map()
+ for (let index = 0; index < uv.count; index++) {
+ const key = normalKey(normal, index)
+ if (!armorArmUvRect(key, bodyPart)) continue
+
+ const u = uv.getX(index)
+ const v = uv.getY(index)
+ const bounds = sourceBounds.get(key)
+ if (bounds) {
+ bounds.minU = Math.min(bounds.minU, u)
+ bounds.maxU = Math.max(bounds.maxU, u)
+ bounds.minV = Math.min(bounds.minV, v)
+ bounds.maxV = Math.max(bounds.maxV, v)
+ } else {
+ sourceBounds.set(key, { minU: u, maxU: u, minV: v, maxV: v })
+ }
+ }
+
+ for (let index = 0; index < uv.count; index++) {
+ const key = normalKey(normal, index)
+ const source = sourceBounds.get(key)
+ const target = armorArmUvRect(key, bodyPart)
+ if (!source || !target) continue
+
+ const uRange = source.maxU - source.minU
+ const vRange = source.maxV - source.minV
+ const sourceURatio = uRange > 0 ? (uv.getX(index) - source.minU) / uRange : 0
+ const uRatio = bodyPart === 'leftArm' ? 1 - sourceURatio : sourceURatio
+ const vRatio = vRange > 0 ? (uv.getY(index) - source.minV) / vRange : 0
+ uv.setXY(
+ index,
+ THREE.MathUtils.lerp(target.minU, target.maxU, uRatio) / 64,
+ THREE.MathUtils.lerp(target.minV, target.maxV, vRatio) / 32,
+ )
+ }
+ uv.needsUpdate = true
+}
+
+function remapArmorUv(
+ uv: THREE.BufferAttribute,
+ normal: THREE.BufferAttribute | undefined,
+ bodyPart: ArmorBodyPart,
+): void {
+ if (isArm(bodyPart) && normal) {
+ // Armor arms always use the classic four-pixel layout, including on slim
+ // players. Rebuild each face's UVs instead of stretching the slim atlas.
+ remapArmUv(uv, normal, bodyPart)
+ return
+ }
+ const leftLegSourceBounds = new Map()
+ if (bodyPart === 'leftLeg' && normal) {
+ for (let index = 0; index < uv.count; index++) {
+ const key = normalKey(normal, index)
+ if (!ARMOR_LEG_UV_RECTS[key]) continue
+ const u = uv.getX(index) - 16 / 64
+ const v = uv.getY(index) - 32 / 64
+ const bounds = leftLegSourceBounds.get(key)
+ if (bounds) {
+ bounds.minU = Math.min(bounds.minU, u)
+ bounds.maxU = Math.max(bounds.maxU, u)
+ bounds.minV = Math.min(bounds.minV, v)
+ bounds.maxV = Math.max(bounds.maxV, v)
+ } else {
+ leftLegSourceBounds.set(key, { minU: u, maxU: u, minV: v, maxV: v })
+ }
+ }
+ }
+
+ for (let index = 0; index < uv.count; index++) {
+ let u = uv.getX(index)
+ let v = uv.getY(index)
+
+ // Vanilla armor textures are 64x32. The left arm and leg reuse the right
+ // limb regions, while player skins store their left-limb pixels in the
+ // lower half of a 64x64 texture.
+ if (bodyPart === 'leftLeg') {
+ u -= 16 / 64
+ v -= 32 / 64
+ const normalKeyValue = normal ? normalKey(normal, index) : undefined
+ const target = normalKeyValue && armorLegUvRect(normalKeyValue, bodyPart)
+ const source = normalKeyValue && leftLegSourceBounds.get(normalKeyValue)
+ if (target && source) {
+ const ratio =
+ source.maxU > source.minU ? (u - source.minU) / (source.maxU - source.minU) : 0
+ u = (target.minU + (1 - ratio) * (target.maxU - target.minU)) / 64
+ }
+ }
+
+ uv.setXY(index, u, v * 2)
+ }
+ uv.needsUpdate = true
+}
+
+export function createArmorGeometry(
+ source: THREE.BufferGeometry,
+ layer: ArmorGeometryLayer,
+ bodyPart: ArmorBodyPart,
+): THREE.BufferGeometry {
+ const geometry = source.clone()
+ const position = geometry.getAttribute('position') as THREE.BufferAttribute | undefined
+ if (!position) throw new Error('Armor source geometry has no position attribute')
+
+ const bounds = new THREE.Box3().setFromBufferAttribute(position)
+ const sourceCenter = bounds.getCenter(new THREE.Vector3())
+ const targetCenter = sourceCenter.clone()
+ const sourceSize = bounds.getSize(new THREE.Vector3())
+ let dilation = layer === 'leggings' ? LEGGINGS_ARMOR_DILATION : OUTER_ARMOR_DILATION
+ if (isLeg(bodyPart)) dilation -= LEG_DEFORMATION_REDUCTION
+ const baseSize = sourceSize.clone()
+ if (isArm(bodyPart) && sourceSize.x < CLASSIC_ARM_WIDTH) {
+ const missingWidth = CLASSIC_ARM_WIDTH - sourceSize.x
+ baseSize.x = CLASSIC_ARM_WIDTH
+ // Slim arms share their inner edge with classic arms. The extra model
+ // pixel therefore belongs on the outside, not equally on both sides.
+ targetCenter.x += bodyPart === 'rightArm' ? missingWidth / 2 : -missingWidth / 2
+ }
+
+ const targetSize = baseSize.addScalar(dilation * 2)
+ const scale = new THREE.Vector3(
+ targetSize.x / sourceSize.x,
+ targetSize.y / sourceSize.y,
+ targetSize.z / sourceSize.z,
+ )
+ const vertex = new THREE.Vector3()
+ for (let index = 0; index < position.count; index++) {
+ vertex.fromBufferAttribute(position, index).sub(sourceCenter).multiply(scale).add(targetCenter)
+ position.setXYZ(index, vertex.x, vertex.y, vertex.z)
+ }
+ position.needsUpdate = true
+
+ const uv = geometry.getAttribute('uv') as THREE.BufferAttribute | undefined
+ const normal = geometry.getAttribute('normal') as THREE.BufferAttribute | undefined
+ if (uv) remapArmorUv(uv, normal, bodyPart)
+
+ geometry.computeBoundingBox()
+ geometry.computeBoundingSphere()
+ return geometry
+}
diff --git a/packages/ui/src/utils/webgl/armor-preview-object.ts b/packages/ui/src/utils/webgl/armor-preview-object.ts
new file mode 100644
index 0000000..e6048d4
--- /dev/null
+++ b/packages/ui/src/utils/webgl/armor-preview-object.ts
@@ -0,0 +1,7 @@
+import type * as THREE from 'three'
+
+export const ARMOR_PREVIEW_MARKER = 'armorPreviewOwned'
+
+export function isArmorPreviewMesh(mesh: THREE.Mesh): boolean {
+ return Boolean(mesh.userData[ARMOR_PREVIEW_MARKER])
+}
diff --git a/packages/ui/src/utils/webgl/armor-rendering.ts b/packages/ui/src/utils/webgl/armor-rendering.ts
new file mode 100644
index 0000000..fd1da5b
--- /dev/null
+++ b/packages/ui/src/utils/webgl/armor-rendering.ts
@@ -0,0 +1,343 @@
+import * as THREE from 'three'
+
+import {
+ type ArmorTextureLayer,
+ getArmorTextureUrl,
+ getLeatherOverlayUrl,
+ getTrimPaletteUrl,
+ getTrimPatternTextureUrl,
+ getTrimSourcePaletteUrl,
+} from '../../composables/skin-rendering/armor-preview-assets'
+import type {
+ ArmorMaterial,
+ ArmorPreviewConfig,
+ ArmorSlot,
+ ArmorTrimMaterial,
+} from '../../composables/skin-rendering/armor-preview-types'
+import { type ArmorBodyPart, type ArmorGeometryLayer, createArmorGeometry } from './armor-geometry'
+import { ARMOR_PREVIEW_MARKER } from './armor-preview-object'
+
+const DEFAULT_LEATHER_COLOR = 0xa06540
+
+interface ArmorPartDefinition {
+ sourceName: string
+ bodyPart: ArmorBodyPart
+}
+
+interface PreparedArmorPiece {
+ baseTexture: THREE.Texture
+ overlayTexture?: THREE.Texture
+ trimTexture?: THREE.Texture
+}
+
+export type PreparedArmorPreview = Partial>
+
+const ARMOR_PARTS: Record = {
+ helmet: [{ sourceName: 'Head_2', bodyPart: 'head' }],
+ chestplate: [
+ { sourceName: 'Body_2', bodyPart: 'body' },
+ { sourceName: 'Right_Arm_2', bodyPart: 'rightArm' },
+ { sourceName: 'Left_Arm_2', bodyPart: 'leftArm' },
+ ],
+ leggings: [
+ { sourceName: 'Body_2', bodyPart: 'body' },
+ { sourceName: 'Right_Leg_2', bodyPart: 'rightLeg' },
+ { sourceName: 'Left_Leg_2', bodyPart: 'leftLeg' },
+ ],
+ boots: [
+ { sourceName: 'Right_Leg_2', bodyPart: 'rightLeg' },
+ { sourceName: 'Left_Leg_2', bodyPart: 'leftLeg' },
+ ],
+}
+
+const textureCache = new Map>()
+const imageCache = new Map>()
+const trimTextureCache = new Map>()
+
+function textureLayerForSlot(slot: ArmorSlot): ArmorTextureLayer {
+ return slot === 'leggings' ? 'leggings' : 'humanoid'
+}
+
+function geometryLayerForSlot(slot: ArmorSlot): ArmorGeometryLayer {
+ return slot === 'leggings' ? 'leggings' : 'outer'
+}
+
+function loadTexture(url: string): Promise {
+ const cached = textureCache.get(url)
+ if (cached) return cached
+
+ const promise = new THREE.TextureLoader().loadAsync(url).then((texture) => {
+ texture.colorSpace = THREE.SRGBColorSpace
+ texture.flipY = false
+ texture.magFilter = THREE.NearestFilter
+ texture.minFilter = THREE.NearestFilter
+ texture.generateMipmaps = false
+ return texture
+ })
+ textureCache.set(url, promise)
+ return promise
+}
+
+function loadImage(url: string): Promise {
+ const cached = imageCache.get(url)
+ if (cached) return cached
+
+ const promise = new Promise((resolve, reject) => {
+ const image = new Image()
+ image.crossOrigin = 'anonymous'
+ image.onload = () => resolve(image)
+ image.onerror = () => reject(new Error(`Failed to load armor preview image: ${url}`))
+ image.src = url
+ })
+ imageCache.set(url, promise)
+ return promise
+}
+
+function imagePixels(image: CanvasImageSource, width: number, height: number): ImageData {
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+ const context = canvas.getContext('2d', { willReadFrequently: true })
+ if (!context) throw new Error('Unable to read armor preview texture pixels')
+ context.drawImage(image, 0, 0, width, height)
+ return context.getImageData(0, 0, width, height)
+}
+
+function closestPaletteIndex(
+ red: number,
+ green: number,
+ blue: number,
+ palette: Uint8ClampedArray,
+): number {
+ let closest = 0
+ let closestDistance = Number.POSITIVE_INFINITY
+ for (let index = 0; index < palette.length; index += 4) {
+ const redDistance = red - palette[index]
+ const greenDistance = green - palette[index + 1]
+ const blueDistance = blue - palette[index + 2]
+ const distance =
+ redDistance * redDistance + greenDistance * greenDistance + blueDistance * blueDistance
+ if (distance < closestDistance) {
+ closest = index
+ closestDistance = distance
+ }
+ }
+ return closest
+}
+
+async function createTrimTexture(patternUrl: string, paletteUrl: string): Promise {
+ const cacheKey = `${patternUrl}|${paletteUrl}`
+ const cached = trimTextureCache.get(cacheKey)
+ if (cached) return cached
+
+ const promise = Promise.all([
+ loadImage(patternUrl),
+ loadImage(getTrimSourcePaletteUrl()),
+ loadImage(paletteUrl),
+ ]).then(([patternImage, sourcePaletteImage, targetPaletteImage]) => {
+ const width = patternImage.naturalWidth
+ const height = patternImage.naturalHeight
+ const pattern = imagePixels(patternImage, width, height)
+ const sourcePalette = imagePixels(
+ sourcePaletteImage,
+ sourcePaletteImage.naturalWidth,
+ sourcePaletteImage.naturalHeight,
+ ).data
+ const targetPalette = imagePixels(
+ targetPaletteImage,
+ targetPaletteImage.naturalWidth,
+ targetPaletteImage.naturalHeight,
+ ).data
+
+ for (let index = 0; index < pattern.data.length; index += 4) {
+ if (pattern.data[index + 3] === 0) continue
+ const paletteIndex = closestPaletteIndex(
+ pattern.data[index],
+ pattern.data[index + 1],
+ pattern.data[index + 2],
+ sourcePalette,
+ )
+ pattern.data[index] = targetPalette[paletteIndex]
+ pattern.data[index + 1] = targetPalette[paletteIndex + 1]
+ pattern.data[index + 2] = targetPalette[paletteIndex + 2]
+ }
+
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+ const context = canvas.getContext('2d')
+ if (!context) throw new Error('Unable to create armor trim texture')
+ context.putImageData(pattern, 0, 0)
+
+ const texture = new THREE.CanvasTexture(canvas)
+ texture.colorSpace = THREE.SRGBColorSpace
+ texture.flipY = false
+ texture.magFilter = THREE.NearestFilter
+ texture.minFilter = THREE.NearestFilter
+ texture.generateMipmaps = false
+ texture.needsUpdate = true
+ return texture
+ })
+ trimTextureCache.set(cacheKey, promise)
+ return promise
+}
+
+function usesDarkerTrim(material: ArmorMaterial, trimMaterial: ArmorTrimMaterial): boolean {
+ return (
+ material !== 'leather' &&
+ material !== 'chainmail' &&
+ material !== 'turtle' &&
+ material === trimMaterial
+ )
+}
+
+async function prepareArmorPiece(
+ slot: ArmorSlot,
+ selection: ArmorPreviewConfig[ArmorSlot],
+): Promise {
+ if (!selection.material) return undefined
+ const layer = textureLayerForSlot(slot)
+ const tasks: Array> = [
+ loadTexture(getArmorTextureUrl(selection.material, layer)),
+ selection.material === 'leather'
+ ? loadTexture(getLeatherOverlayUrl(layer))
+ : Promise.resolve(undefined),
+ ]
+
+ if (selection.trimPattern) {
+ tasks.push(
+ createTrimTexture(
+ getTrimPatternTextureUrl(selection.trimPattern, layer),
+ getTrimPaletteUrl(
+ selection.trimMaterial,
+ usesDarkerTrim(selection.material, selection.trimMaterial),
+ ),
+ ),
+ )
+ } else {
+ tasks.push(Promise.resolve(undefined))
+ }
+
+ const [baseTexture, overlayTexture, trimTexture] = await Promise.all(tasks)
+ return { baseTexture: baseTexture!, overlayTexture, trimTexture }
+}
+
+export async function prepareArmorPreview(
+ config: ArmorPreviewConfig,
+): Promise {
+ const entries = await Promise.all(
+ (Object.entries(config) as Array<[ArmorSlot, ArmorPreviewConfig[ArmorSlot]]>).map(
+ async ([slot, selection]) => [slot, await prepareArmorPiece(slot, selection)] as const,
+ ),
+ )
+ return Object.fromEntries(entries.filter((entry) => entry[1])) as PreparedArmorPreview
+}
+
+function createArmorMaterial(
+ texture: THREE.Texture,
+ options: { color?: number; polygonOffset?: number; renderOrder: number },
+): THREE.MeshStandardMaterial {
+ const material = new THREE.MeshStandardMaterial({
+ map: texture,
+ alphaTest: 0.1,
+ color: options.color ?? 0xffffff,
+ depthTest: true,
+ depthWrite: true,
+ flatShading: true,
+ metalness: 0,
+ roughness: 1,
+ side: THREE.FrontSide,
+ toneMapped: false,
+ transparent: false,
+ })
+ if (options.polygonOffset !== undefined) {
+ material.polygonOffset = true
+ material.polygonOffsetFactor = options.polygonOffset
+ material.polygonOffsetUnits = options.polygonOffset
+ }
+ material.userData.armorPreviewRenderOrder = options.renderOrder
+ return material
+}
+
+function attachArmorMesh(
+ source: THREE.Mesh,
+ geometry: THREE.BufferGeometry,
+ material: THREE.MeshStandardMaterial,
+ name: string,
+): void {
+ const mesh = new THREE.Mesh(geometry, material)
+ mesh.name = name
+ mesh.position.copy(source.position)
+ mesh.quaternion.copy(source.quaternion)
+ mesh.scale.copy(source.scale)
+ mesh.renderOrder = material.userData.armorPreviewRenderOrder as number
+ mesh.frustumCulled = source.frustumCulled
+ mesh.userData[ARMOR_PREVIEW_MARKER] = true
+ source.parent?.add(mesh)
+}
+
+function attachArmorPart(
+ source: THREE.Mesh,
+ slot: ArmorSlot,
+ bodyPart: ArmorBodyPart,
+ selection: ArmorPreviewConfig[ArmorSlot],
+ prepared: PreparedArmorPiece,
+): void {
+ const geometry = createArmorGeometry(source.geometry, geometryLayerForSlot(slot), bodyPart)
+ attachArmorMesh(
+ source,
+ geometry,
+ createArmorMaterial(prepared.baseTexture, {
+ color: selection.material === 'leather' ? DEFAULT_LEATHER_COLOR : undefined,
+ renderOrder: 2,
+ }),
+ `Armor_${slot}_${source.name}`,
+ )
+
+ if (prepared.overlayTexture) {
+ attachArmorMesh(
+ source,
+ geometry.clone(),
+ createArmorMaterial(prepared.overlayTexture, { polygonOffset: -1, renderOrder: 3 }),
+ `Armor_${slot}_${source.name}_Overlay`,
+ )
+ }
+ if (prepared.trimTexture) {
+ attachArmorMesh(
+ source,
+ geometry.clone(),
+ createArmorMaterial(prepared.trimTexture, { polygonOffset: -2, renderOrder: 4 }),
+ `Armor_${slot}_${source.name}_Trim`,
+ )
+ }
+}
+
+export function removeArmorPreview(root: THREE.Object3D): void {
+ const owned: THREE.Mesh[] = []
+ root.traverse((object) => {
+ const mesh = object as THREE.Mesh
+ if (mesh.isMesh && mesh.userData[ARMOR_PREVIEW_MARKER]) owned.push(mesh)
+ })
+ for (const mesh of owned) {
+ mesh.parent?.remove(mesh)
+ mesh.geometry.dispose()
+ const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
+ materials.forEach((material) => material.dispose())
+ }
+}
+
+export function applyPreparedArmorPreview(
+ root: THREE.Object3D,
+ config: ArmorPreviewConfig,
+ prepared: PreparedArmorPreview,
+): void {
+ removeArmorPreview(root)
+ for (const [slot, piece] of Object.entries(prepared) as Array<[ArmorSlot, PreparedArmorPiece]>) {
+ const selection = config[slot]
+ for (const part of ARMOR_PARTS[slot]) {
+ const source = root.getObjectByName(part.sourceName) as THREE.Mesh | undefined
+ if (!source?.isMesh || !source.parent) continue
+ attachArmorPart(source, slot, part.bodyPart, selection, piece)
+ }
+ }
+}