diff --git a/apps/app-frontend/src/components/ui/skin/ArmorTrimTab.vue b/apps/app-frontend/src/components/ui/skin/ArmorTrimTab.vue new file mode 100644 index 0000000..6ff9263 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/ArmorTrimTab.vue @@ -0,0 +1,123 @@ + + + diff --git a/apps/app-frontend/src/components/ui/skin/SkinListSkeleton.vue b/apps/app-frontend/src/components/ui/skin/SkinListSkeleton.vue new file mode 100644 index 0000000..0bf8fc3 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin/SkinListSkeleton.vue @@ -0,0 +1,28 @@ + + + diff --git a/apps/app-frontend/src/helpers/skin-armor-preview.test.ts b/apps/app-frontend/src/helpers/skin-armor-preview.test.ts new file mode 100644 index 0000000..531ac14 --- /dev/null +++ b/apps/app-frontend/src/helpers/skin-armor-preview.test.ts @@ -0,0 +1,60 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { loadSkinArmorPreview, saveSkinArmorPreview } from './skin-armor-preview.ts' + +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage') + +function withStorage(run: (values: Map) => void): void { + const values = new Map() + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }, + }) + try { + run(values) + } finally { + if (originalStorage) Object.defineProperty(globalThis, 'localStorage', originalStorage) + else delete (globalThis as { localStorage?: Storage }).localStorage + } +} + +test('armor preview selections survive storage round trips', () => { + withStorage(() => { + const config = loadSkinArmorPreview() + config.helmet.material = 'diamond' + config.helmet.trimPattern = 'sentry' + config.helmet.trimMaterial = 'gold' + saveSkinArmorPreview(config) + + assert.deepEqual(loadSkinArmorPreview(), config) + }) +}) + +test('invalid stored armor values fall back per slot and field', () => { + withStorage((values) => { + values.set( + 'axolotl:skin-armor-preview', + JSON.stringify({ + helmet: { material: 'diamond', trimPattern: 'invalid', trimMaterial: 'gold' }, + boots: { material: 'turtle', trimPattern: 'sentry', trimMaterial: 'invalid' }, + }), + ) + + const config = loadSkinArmorPreview() + assert.deepEqual(config.helmet, { + material: 'diamond', + trimPattern: null, + trimMaterial: 'gold', + }) + assert.deepEqual(config.boots, { + material: null, + trimPattern: 'sentry', + trimMaterial: 'iron', + }) + assert.equal(config.chestplate.material, null) + }) +}) diff --git a/apps/app-frontend/src/helpers/skin-armor-preview.ts b/apps/app-frontend/src/helpers/skin-armor-preview.ts new file mode 100644 index 0000000..a206611 --- /dev/null +++ b/apps/app-frontend/src/helpers/skin-armor-preview.ts @@ -0,0 +1,47 @@ +import { + ARMOR_SLOTS, + ARMOR_TRIM_MATERIALS, + ARMOR_TRIM_PATTERNS, + armorMaterialsForSlot, + type ArmorPreviewConfig, + createDefaultArmorPreviewConfig, +} from '@modrinth/ui' + +const STORAGE_KEY = 'axolotl:skin-armor-preview' + +function includes(values: readonly T[], value: unknown): value is T { + return typeof value === 'string' && values.some((item) => item === value) +} + +export function loadSkinArmorPreview(): ArmorPreviewConfig { + const config = createDefaultArmorPreviewConfig() + try { + const saved = JSON.parse(globalThis.localStorage?.getItem(STORAGE_KEY) ?? 'null') + if (!saved || typeof saved !== 'object' || Array.isArray(saved)) return config + + for (const slot of ARMOR_SLOTS) { + const piece = saved[slot] + if (!piece || typeof piece !== 'object' || Array.isArray(piece)) continue + if (piece.material === null || includes(armorMaterialsForSlot(slot), piece.material)) { + config[slot].material = piece.material + } + if (piece.trimPattern === null || includes(ARMOR_TRIM_PATTERNS, piece.trimPattern)) { + config[slot].trimPattern = piece.trimPattern + } + if (includes(ARMOR_TRIM_MATERIALS, piece.trimMaterial)) { + config[slot].trimMaterial = piece.trimMaterial + } + } + } catch { + // Invalid or unavailable local storage falls back to a fresh preview. + } + return config +} + +export function saveSkinArmorPreview(config: ArmorPreviewConfig): void { + try { + globalThis.localStorage?.setItem(STORAGE_KEY, JSON.stringify(config)) + } catch { + // The preview remains usable when local storage is unavailable. + } +} diff --git a/apps/app-frontend/src/pages/Skins.vue b/apps/app-frontend/src/pages/Skins.vue index e9d8008..e55a35c 100644 --- a/apps/app-frontend/src/pages/Skins.vue +++ b/apps/app-frontend/src/pages/Skins.vue @@ -9,9 +9,13 @@ import { SpinnerIcon, } from '@modrinth/assets' import { + type ArmorPreviewConfig, + ArmorPreviewControls, ButtonStyled, + cloneArmorPreviewConfig, commonMessages, ConfirmModal, + createDefaultArmorPreviewConfig, defineMessages, injectNotificationManager, NavTabs, @@ -24,12 +28,14 @@ import { invoke } from '@tauri-apps/api/core' import type { DragDropEvent } from '@tauri-apps/api/webview' import { getCurrentWebview } from '@tauri-apps/api/webview' import { computedAsync } from '@vueuse/core' -import type { Ref } from 'vue' +import type { ComponentPublicInstance, Ref } from 'vue' import { computed, inject, onMounted, onUnmounted, ref, useTemplateRef, watch } from 'vue' import { onBeforeRouteLeave, useRouter } from 'vue-router' import type AccountsCard from '@/components/ui/AccountsCard.vue' +import ArmorTrimTab from '@/components/ui/skin/ArmorTrimTab.vue' import EditSkinModal from '@/components/ui/skin/EditSkinModal.vue' +import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue' import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue' import { openSkinSiteLogin, @@ -45,6 +51,7 @@ import { useNetworkStatus } from '@/composables/useNetworkStatus' import { check_reachable, get_default_user, users } from '@/helpers/auth' import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts' import { skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts' +import { loadSkinArmorPreview, saveSkinArmorPreview } from '@/helpers/skin-armor-preview' import type { Cape, Skin, SkinTextureUrl } from '@/helpers/skins.ts' import { determineModelType, @@ -252,6 +259,8 @@ const editSkinModal = useTemplateRef('editSkinModal') const addSkinFileInput = useTemplateRef('addSkinFileInput') const skinSectionList = useTemplateRef('skinSectionList') const skinPreviewArea = useTemplateRef('skinPreviewArea') +const armorTrimTab = useTemplateRef('armorTrimTab') +const armorPreviewControls = useTemplateRef('armorPreviewControls') const { formatMessage } = useVIntl() const router = useRouter() @@ -391,7 +400,8 @@ const isAddSkinButtonDragActive = ref(false) const deleteSkinModal = ref() const skinToDelete = ref(null) -const skinListTab = ref<'saved' | 'default'>('saved') +const lastSkinListTab = ref<'saved' | 'default'>('saved') +const skinListTab = ref<'saved' | 'default' | 'armor'>('saved') const skinListTabLinks = computed(() => [ { @@ -403,6 +413,70 @@ const skinListTabLinks = computed(() => [ label: formatMessage(messages.defaultTab), }, ]) +const ARMOR_TRIM_PANEL_ID = 'skins-armor-trim-panel' +const savedArmorPreview = ref(loadSkinArmorPreview()) +const armorPreviewConfig = ref(cloneArmorPreviewConfig(savedArmorPreview.value)) + +function saveArmorPreview() { + savedArmorPreview.value = cloneArmorPreviewConfig(armorPreviewConfig.value) + saveSkinArmorPreview(savedArmorPreview.value) +} + +function resetArmorPreviewToSaved() { + armorPreviewConfig.value = cloneArmorPreviewConfig(savedArmorPreview.value) +} + +function restoreDefaultArmorPreview() { + armorPreviewConfig.value = createDefaultArmorPreviewConfig() +} + +/** A third tab behind the two list tabs, opened from the preview's action row. */ +function openArmorTab() { + if (skinListTab.value !== 'armor') lastSkinListTab.value = skinListTab.value + + skinListTab.value = 'armor' +} + +/** + * Whether an element belongs to the armour surface: somewhere inside the open + * armour tab or the button that toggles it. Only those hold a focus ring that + * would be left behind when the tab closes. + */ +function isArmorPreviewElement(element: Element) { + const tabElement: unknown = armorTrimTab.value?.$el + const toggleElement: unknown = armorPreviewControls.value?.$el + + return ( + (tabElement instanceof Element && tabElement.contains(element)) || + (toggleElement instanceof Element && toggleElement.contains(element)) + ) +} + +function closeArmorTab() { + // Leaving the tab drops whatever was not saved on purpose. + resetArmorPreviewToSaved() + skinListTab.value = lastSkinListTab.value + + // Escape and the toggle both leave the armour button holding focus, which + // keeps its focus ring drawn until something else is clicked. Only drop that + // ring when the focus sits on the armour surface, never on an unrelated + // control that happened to hold it when Escape was pressed. + const focused = document.activeElement + if (focused instanceof HTMLElement && isArmorPreviewElement(focused)) focused.blur() +} + +/** + * The tab bar keeps the last list tab highlighted while the armour tab shows, so + * picking one of them leaves the armour tab. That transition has to drop the + * draft exactly like the toggle and Escape paths do, or it lingers on the model + * while the panel is gone. + */ +function selectSkinListTab(index: number) { + if (skinListTab.value === 'armor') resetArmorPreviewToSaved() + + lastSkinListTab.value = index === 0 ? 'saved' : 'default' + skinListTab.value = lastSkinListTab.value +} function confirmDeleteSkin(skin: Skin) { if (isSkinManagementReadOnly.value) return @@ -1211,6 +1285,9 @@ await loadSkins() class="ml-5 mt-4 flex h-[calc(80vh-1rem)] items-center justify-center max-[700px]:h-[calc(50vh-1rem)]" > {{ formatMessage(messages.editSkinButton) }} + @@ -1265,14 +1352,10 @@ await loadSkins()
- + +
+ + +
+ +
@@ -1382,4 +1483,17 @@ await loadSkins() grid-template-columns: 1fr; } } +// The armour tab crossfades with the skin list it replaces. +.armor-tab-enter-active, +.armor-tab-leave-active { + transition: + opacity 180ms ease, + transform 180ms ease; +} + +.armor-tab-enter-from, +.armor-tab-leave-to { + opacity: 0; + transform: translateY(0.5rem); +} diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/chainmail.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/chainmail.png new file mode 100644 index 0000000..b4e4aac Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/chainmail.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/copper.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/copper.png new file mode 100644 index 0000000..eccf785 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/copper.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/diamond.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/diamond.png new file mode 100644 index 0000000..b3dc020 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/diamond.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/gold.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/gold.png new file mode 100644 index 0000000..bbd3011 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/gold.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/iron.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/iron.png new file mode 100644 index 0000000..9c54e7e Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/iron.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/leather.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/leather.png new file mode 100644 index 0000000..2ebea47 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/leather.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/leather_overlay.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/leather_overlay.png new file mode 100644 index 0000000..8b9e75d Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/leather_overlay.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/netherite.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/netherite.png new file mode 100644 index 0000000..11c961e Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/netherite.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/humanoid/turtle_scute.png b/packages/ui/src/assets/minecraft-armor/entity/humanoid/turtle_scute.png new file mode 100644 index 0000000..d536fa7 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/humanoid/turtle_scute.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/chainmail.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/chainmail.png new file mode 100644 index 0000000..0d47920 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/leggings/chainmail.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/copper.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/copper.png new file mode 100644 index 0000000..fc15738 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/leggings/copper.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/diamond.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/diamond.png new file mode 100644 index 0000000..a9d69e8 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/leggings/diamond.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/gold.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/gold.png new file mode 100644 index 0000000..0d1032e Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/leggings/gold.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/iron.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/iron.png new file mode 100644 index 0000000..f45fb53 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/leggings/iron.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/leather.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/leather.png new file mode 100644 index 0000000..527b526 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/leggings/leather.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/leather_overlay.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/leather_overlay.png new file mode 100644 index 0000000..a6339f8 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/leggings/leather_overlay.png differ diff --git a/packages/ui/src/assets/minecraft-armor/entity/leggings/netherite.png b/packages/ui/src/assets/minecraft-armor/entity/leggings/netherite.png new file mode 100644 index 0000000..8668fb4 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/entity/leggings/netherite.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/amethyst_shard.png b/packages/ui/src/assets/minecraft-armor/item/amethyst_shard.png new file mode 100644 index 0000000..caca1e3 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/amethyst_shard.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/bolt_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/bolt_armor_trim_smithing_template.png new file mode 100644 index 0000000..e741cf9 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/bolt_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/chainmail_boots.png b/packages/ui/src/assets/minecraft-armor/item/chainmail_boots.png new file mode 100644 index 0000000..d7d832a Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/chainmail_boots.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/chainmail_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/chainmail_chestplate.png new file mode 100644 index 0000000..8f69616 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/chainmail_chestplate.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/chainmail_helmet.png b/packages/ui/src/assets/minecraft-armor/item/chainmail_helmet.png new file mode 100644 index 0000000..d07206a Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/chainmail_helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/chainmail_leggings.png b/packages/ui/src/assets/minecraft-armor/item/chainmail_leggings.png new file mode 100644 index 0000000..494bc50 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/chainmail_leggings.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/coast_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/coast_armor_trim_smithing_template.png new file mode 100644 index 0000000..b5c23c7 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/coast_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/copper_boots.png b/packages/ui/src/assets/minecraft-armor/item/copper_boots.png new file mode 100644 index 0000000..11c0cb4 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/copper_boots.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/copper_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/copper_chestplate.png new file mode 100644 index 0000000..d7507b0 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/copper_chestplate.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/copper_helmet.png b/packages/ui/src/assets/minecraft-armor/item/copper_helmet.png new file mode 100644 index 0000000..fde1812 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/copper_helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/copper_ingot.png b/packages/ui/src/assets/minecraft-armor/item/copper_ingot.png new file mode 100644 index 0000000..ca72100 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/copper_ingot.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/copper_leggings.png b/packages/ui/src/assets/minecraft-armor/item/copper_leggings.png new file mode 100644 index 0000000..66f1f38 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/copper_leggings.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond.png b/packages/ui/src/assets/minecraft-armor/item/diamond.png new file mode 100644 index 0000000..83ac14d Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/diamond.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond_boots.png b/packages/ui/src/assets/minecraft-armor/item/diamond_boots.png new file mode 100644 index 0000000..820291b Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/diamond_boots.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/diamond_chestplate.png new file mode 100644 index 0000000..0e74605 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/diamond_chestplate.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond_helmet.png b/packages/ui/src/assets/minecraft-armor/item/diamond_helmet.png new file mode 100644 index 0000000..0ad5415 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/diamond_helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/diamond_leggings.png b/packages/ui/src/assets/minecraft-armor/item/diamond_leggings.png new file mode 100644 index 0000000..5344002 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/diamond_leggings.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/dune_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/dune_armor_trim_smithing_template.png new file mode 100644 index 0000000..d834602 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/dune_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/emerald.png b/packages/ui/src/assets/minecraft-armor/item/emerald.png new file mode 100644 index 0000000..88ee6de Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/emerald.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/eye_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/eye_armor_trim_smithing_template.png new file mode 100644 index 0000000..991c297 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/eye_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/flow_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/flow_armor_trim_smithing_template.png new file mode 100644 index 0000000..27884b1 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/flow_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/gold_ingot.png b/packages/ui/src/assets/minecraft-armor/item/gold_ingot.png new file mode 100644 index 0000000..d623b0c Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/gold_ingot.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/golden_boots.png b/packages/ui/src/assets/minecraft-armor/item/golden_boots.png new file mode 100644 index 0000000..982ca4d Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/golden_boots.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/golden_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/golden_chestplate.png new file mode 100644 index 0000000..2a2c8c3 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/golden_chestplate.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/golden_helmet.png b/packages/ui/src/assets/minecraft-armor/item/golden_helmet.png new file mode 100644 index 0000000..d56a41b Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/golden_helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/golden_leggings.png b/packages/ui/src/assets/minecraft-armor/item/golden_leggings.png new file mode 100644 index 0000000..104c394 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/golden_leggings.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/host_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/host_armor_trim_smithing_template.png new file mode 100644 index 0000000..8b472b3 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/host_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/iron_boots.png b/packages/ui/src/assets/minecraft-armor/item/iron_boots.png new file mode 100644 index 0000000..ef81e41 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/iron_boots.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/iron_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/iron_chestplate.png new file mode 100644 index 0000000..7d59690 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/iron_chestplate.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/iron_helmet.png b/packages/ui/src/assets/minecraft-armor/item/iron_helmet.png new file mode 100644 index 0000000..4a9925c Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/iron_helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/iron_ingot.png b/packages/ui/src/assets/minecraft-armor/item/iron_ingot.png new file mode 100644 index 0000000..9e1709e Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/iron_ingot.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/iron_leggings.png b/packages/ui/src/assets/minecraft-armor/item/iron_leggings.png new file mode 100644 index 0000000..bbff7c8 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/iron_leggings.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/lapis_lazuli.png b/packages/ui/src/assets/minecraft-armor/item/lapis_lazuli.png new file mode 100644 index 0000000..f70fa8d Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/lapis_lazuli.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/leather_default_boots.png b/packages/ui/src/assets/minecraft-armor/item/leather_default_boots.png new file mode 100644 index 0000000..19683e5 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/leather_default_boots.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/leather_default_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/leather_default_chestplate.png new file mode 100644 index 0000000..9abfe80 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/leather_default_chestplate.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/leather_default_helmet.png b/packages/ui/src/assets/minecraft-armor/item/leather_default_helmet.png new file mode 100644 index 0000000..2daef6a Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/leather_default_helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/leather_default_leggings.png b/packages/ui/src/assets/minecraft-armor/item/leather_default_leggings.png new file mode 100644 index 0000000..071f52a Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/leather_default_leggings.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_boots.png b/packages/ui/src/assets/minecraft-armor/item/netherite_boots.png new file mode 100644 index 0000000..e5ef073 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/netherite_boots.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_chestplate.png b/packages/ui/src/assets/minecraft-armor/item/netherite_chestplate.png new file mode 100644 index 0000000..5181bac Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/netherite_chestplate.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_helmet.png b/packages/ui/src/assets/minecraft-armor/item/netherite_helmet.png new file mode 100644 index 0000000..ca34f3b Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/netherite_helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_ingot.png b/packages/ui/src/assets/minecraft-armor/item/netherite_ingot.png new file mode 100644 index 0000000..9842303 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/netherite_ingot.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/netherite_leggings.png b/packages/ui/src/assets/minecraft-armor/item/netherite_leggings.png new file mode 100644 index 0000000..af45363 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/netherite_leggings.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/quartz.png b/packages/ui/src/assets/minecraft-armor/item/quartz.png new file mode 100644 index 0000000..4585457 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/quartz.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/raiser_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/raiser_armor_trim_smithing_template.png new file mode 100644 index 0000000..5f0c880 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/raiser_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/redstone.png b/packages/ui/src/assets/minecraft-armor/item/redstone.png new file mode 100644 index 0000000..6d1df15 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/redstone.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/resin_brick.png b/packages/ui/src/assets/minecraft-armor/item/resin_brick.png new file mode 100644 index 0000000..33642c8 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/resin_brick.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/rib_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/rib_armor_trim_smithing_template.png new file mode 100644 index 0000000..35e36ab Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/rib_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/sentry_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/sentry_armor_trim_smithing_template.png new file mode 100644 index 0000000..871898d Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/sentry_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/shaper_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/shaper_armor_trim_smithing_template.png new file mode 100644 index 0000000..c07cba8 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/shaper_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/silence_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/silence_armor_trim_smithing_template.png new file mode 100644 index 0000000..ea30a23 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/silence_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/snout_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/snout_armor_trim_smithing_template.png new file mode 100644 index 0000000..22859c9 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/snout_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/spire_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/spire_armor_trim_smithing_template.png new file mode 100644 index 0000000..ddd74bf Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/spire_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/tide_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/tide_armor_trim_smithing_template.png new file mode 100644 index 0000000..290b69c Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/tide_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/turtle_helmet.png b/packages/ui/src/assets/minecraft-armor/item/turtle_helmet.png new file mode 100644 index 0000000..06c2dea Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/turtle_helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/vex_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/vex_armor_trim_smithing_template.png new file mode 100644 index 0000000..7d2ee01 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/vex_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/ward_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/ward_armor_trim_smithing_template.png new file mode 100644 index 0000000..57d2cea Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/ward_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/wayfinder_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/wayfinder_armor_trim_smithing_template.png new file mode 100644 index 0000000..cd59380 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/wayfinder_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/item/wild_armor_trim_smithing_template.png b/packages/ui/src/assets/minecraft-armor/item/wild_armor_trim_smithing_template.png new file mode 100644 index 0000000..9386839 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/item/wild_armor_trim_smithing_template.png differ diff --git a/packages/ui/src/assets/minecraft-armor/slot/boots.png b/packages/ui/src/assets/minecraft-armor/slot/boots.png new file mode 100644 index 0000000..e7793ce Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/slot/boots.png differ diff --git a/packages/ui/src/assets/minecraft-armor/slot/chestplate.png b/packages/ui/src/assets/minecraft-armor/slot/chestplate.png new file mode 100644 index 0000000..2891f44 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/slot/chestplate.png differ diff --git a/packages/ui/src/assets/minecraft-armor/slot/helmet.png b/packages/ui/src/assets/minecraft-armor/slot/helmet.png new file mode 100644 index 0000000..f7d08aa Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/slot/helmet.png differ diff --git a/packages/ui/src/assets/minecraft-armor/slot/leggings.png b/packages/ui/src/assets/minecraft-armor/slot/leggings.png new file mode 100644 index 0000000..cd97b29 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/slot/leggings.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/bolt.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/bolt.png new file mode 100644 index 0000000..e639166 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/bolt.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/coast.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/coast.png new file mode 100644 index 0000000..da651f5 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/coast.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/dune.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/dune.png new file mode 100644 index 0000000..185b347 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/dune.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/eye.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/eye.png new file mode 100644 index 0000000..0c9e466 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/eye.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/flow.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/flow.png new file mode 100644 index 0000000..a299e31 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/flow.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/host.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/host.png new file mode 100644 index 0000000..44b3226 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/host.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/raiser.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/raiser.png new file mode 100644 index 0000000..9436d62 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/raiser.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/rib.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/rib.png new file mode 100644 index 0000000..1d79054 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/rib.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/sentry.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/sentry.png new file mode 100644 index 0000000..b93170f Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/sentry.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/shaper.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/shaper.png new file mode 100644 index 0000000..2bbbd1e Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/shaper.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/silence.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/silence.png new file mode 100644 index 0000000..6c6d471 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/silence.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/snout.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/snout.png new file mode 100644 index 0000000..4eb5fa1 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/snout.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/spire.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/spire.png new file mode 100644 index 0000000..cf4fb1a Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/spire.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/tide.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/tide.png new file mode 100644 index 0000000..671a0b8 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/tide.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/vex.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/vex.png new file mode 100644 index 0000000..7560fcf Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/vex.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/ward.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/ward.png new file mode 100644 index 0000000..1fc8f98 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/ward.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/wayfinder.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/wayfinder.png new file mode 100644 index 0000000..6723b86 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/wayfinder.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/humanoid/wild.png b/packages/ui/src/assets/minecraft-armor/trim/humanoid/wild.png new file mode 100644 index 0000000..39fbdb6 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/humanoid/wild.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/bolt.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/bolt.png new file mode 100644 index 0000000..dd6839f Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/bolt.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/coast.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/coast.png new file mode 100644 index 0000000..fdb7895 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/coast.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/dune.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/dune.png new file mode 100644 index 0000000..34445c8 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/dune.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/eye.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/eye.png new file mode 100644 index 0000000..71469be Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/eye.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/flow.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/flow.png new file mode 100644 index 0000000..f587381 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/flow.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/host.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/host.png new file mode 100644 index 0000000..8b7bdd9 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/host.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/raiser.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/raiser.png new file mode 100644 index 0000000..d575793 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/raiser.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/rib.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/rib.png new file mode 100644 index 0000000..2b1a873 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/rib.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/sentry.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/sentry.png new file mode 100644 index 0000000..edd6a28 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/sentry.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/shaper.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/shaper.png new file mode 100644 index 0000000..1b5ac70 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/shaper.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/silence.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/silence.png new file mode 100644 index 0000000..8ecca4d Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/silence.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/snout.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/snout.png new file mode 100644 index 0000000..6058fae Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/snout.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/spire.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/spire.png new file mode 100644 index 0000000..e983ddd Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/spire.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/tide.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/tide.png new file mode 100644 index 0000000..c29985f Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/tide.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/vex.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/vex.png new file mode 100644 index 0000000..211d69c Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/vex.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/ward.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/ward.png new file mode 100644 index 0000000..04c8ac9 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/ward.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/wayfinder.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/wayfinder.png new file mode 100644 index 0000000..2ca5de6 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/wayfinder.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/leggings/wild.png b/packages/ui/src/assets/minecraft-armor/trim/leggings/wild.png new file mode 100644 index 0000000..2c19518 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/leggings/wild.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/amethyst.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/amethyst.png new file mode 100644 index 0000000..ddec960 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/amethyst.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/copper.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/copper.png new file mode 100644 index 0000000..42c2b61 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/copper.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/copper_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/copper_darker.png new file mode 100644 index 0000000..2009171 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/copper_darker.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond.png new file mode 100644 index 0000000..044b4e2 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond_darker.png new file mode 100644 index 0000000..cbf5ed4 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/diamond_darker.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/emerald.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/emerald.png new file mode 100644 index 0000000..bc983ab Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/emerald.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/gold.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/gold.png new file mode 100644 index 0000000..2072231 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/gold.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/gold_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/gold_darker.png new file mode 100644 index 0000000..bd604ff Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/gold_darker.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/iron.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/iron.png new file mode 100644 index 0000000..d52578d Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/iron.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/iron_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/iron_darker.png new file mode 100644 index 0000000..6e3007c Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/iron_darker.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/lapis.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/lapis.png new file mode 100644 index 0000000..9c578f0 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/lapis.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite.png new file mode 100644 index 0000000..03a6e28 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite_darker.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite_darker.png new file mode 100644 index 0000000..ad8f517 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/netherite_darker.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/quartz.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/quartz.png new file mode 100644 index 0000000..1275d27 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/quartz.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/redstone.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/redstone.png new file mode 100644 index 0000000..fb70bf8 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/redstone.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/resin.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/resin.png new file mode 100644 index 0000000..a3cbc06 Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/resin.png differ diff --git a/packages/ui/src/assets/minecraft-armor/trim/palettes/trim_palette.png b/packages/ui/src/assets/minecraft-armor/trim/palettes/trim_palette.png new file mode 100644 index 0000000..6c7c20f Binary files /dev/null and b/packages/ui/src/assets/minecraft-armor/trim/palettes/trim_palette.png differ diff --git a/packages/ui/src/components/skin/ArmorPreviewControls.vue b/packages/ui/src/components/skin/ArmorPreviewControls.vue new file mode 100644 index 0000000..72e071e --- /dev/null +++ b/packages/ui/src/components/skin/ArmorPreviewControls.vue @@ -0,0 +1,60 @@ + + + \ No newline at end of file diff --git a/packages/ui/src/components/skin/ArmorTrimPanel.vue b/packages/ui/src/components/skin/ArmorTrimPanel.vue new file mode 100644 index 0000000..567cca1 --- /dev/null +++ b/packages/ui/src/components/skin/ArmorTrimPanel.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/packages/ui/src/components/skin/SkinPreviewRenderer.vue b/packages/ui/src/components/skin/SkinPreviewRenderer.vue index 6aa12bb..a4f8738 100644 --- a/packages/ui/src/components/skin/SkinPreviewRenderer.vue +++ b/packages/ui/src/components/skin/SkinPreviewRenderer.vue @@ -17,12 +17,17 @@
-
+
+
(), { 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) + } + } +}